feat(lab): add recorded realtime spatial playback
This commit is contained in:
@@ -257,6 +257,25 @@ class RecordedGeometryStore:
|
||||
points.setflags(write=False)
|
||||
return points
|
||||
|
||||
def current_points_for_frame(self, frame_index: int) -> FloatArray | None:
|
||||
"""Expose one verified increment to a read-only recorded-evidence projector."""
|
||||
|
||||
if not isinstance(frame_index, int) or isinstance(frame_index, bool):
|
||||
raise GeometryProviderError("replay evidence frame index is invalid")
|
||||
if not 0 <= frame_index < self.profile.frame_count:
|
||||
raise GeometryProviderError("replay evidence frame is outside the source profile")
|
||||
if int(self._source["frame_indices"][frame_index]) != frame_index:
|
||||
raise GeometryProviderError("source pack frame sequence changed")
|
||||
if not bool(self._source["sample_available"][frame_index]) or not bool(
|
||||
self._surface["frame_valid"][frame_index]
|
||||
):
|
||||
return None
|
||||
offsets = self._source["cloud_offsets"]
|
||||
start, end = int(offsets[frame_index]), int(offsets[frame_index + 1])
|
||||
points = np.asarray(self._source["cloud_points_map"][start:end], dtype=np.float64)
|
||||
points.setflags(write=False)
|
||||
return points
|
||||
|
||||
def pose_values_for_frame(
|
||||
self,
|
||||
frame_id: str,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Deterministic projections shared by recorded spatial evidence producers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from .threat import ReplayBodyFrame
|
||||
|
||||
FloatArray = npt.NDArray[np.float64]
|
||||
|
||||
|
||||
class SpatialEvidenceProjectionError(RuntimeError):
|
||||
"""Recorded spatial evidence cannot be projected without changing meaning."""
|
||||
|
||||
|
||||
def sample_points_in_body_frame(
|
||||
points_map: FloatArray,
|
||||
body_frame: ReplayBodyFrame,
|
||||
*,
|
||||
point_limit: int,
|
||||
) -> tuple[list[list[float]], int]:
|
||||
"""Project one immutable map-frame increment into the current body frame."""
|
||||
|
||||
if point_limit < 1:
|
||||
raise SpatialEvidenceProjectionError("spatial evidence point limit must be positive")
|
||||
points = np.asarray(points_map, dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[1] != 3 or not np.isfinite(points).all():
|
||||
raise SpatialEvidenceProjectionError("spatial evidence point array is invalid")
|
||||
basis = np.asarray(body_frame.basis_map_from_body, dtype=np.float64)
|
||||
origin = np.asarray(body_frame.origin_map_xyz_m, dtype=np.float64)
|
||||
if basis.shape != (3, 3) or origin.shape != (3,):
|
||||
raise SpatialEvidenceProjectionError("spatial evidence body frame is invalid")
|
||||
points_body = (points - origin) @ basis
|
||||
stride = max(1, math.ceil(points_body.shape[0] / point_limit))
|
||||
sampled = points_body[::stride][:point_limit]
|
||||
return np.round(sampled, 6).tolist(), int(points.shape[0])
|
||||
|
||||
|
||||
def project_metric_obstacles_to_body(
|
||||
metric_rows: Sequence[Mapping[str, object]],
|
||||
body_frame: ReplayBodyFrame,
|
||||
*,
|
||||
occupied_voxel_size_m: float,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Project ledger-owned metric components without recomputing their decision."""
|
||||
|
||||
if not math.isfinite(occupied_voxel_size_m) or occupied_voxel_size_m <= 0:
|
||||
raise SpatialEvidenceProjectionError("occupied voxel size is invalid")
|
||||
visuals: list[dict[str, object]] = []
|
||||
for row in metric_rows:
|
||||
centroid = row.get("centroid_map_xyz_m")
|
||||
cells = row.get("cells")
|
||||
if not isinstance(centroid, list) or len(centroid) != 3 or not isinstance(cells, list):
|
||||
continue
|
||||
centroid_map = _finite_vector3(centroid, "metric centroid")
|
||||
centroid_body = body_frame.map_point_to_body(centroid_map)
|
||||
cell_centers: list[list[float]] = []
|
||||
for raw_cell in cells:
|
||||
if not isinstance(raw_cell, dict):
|
||||
raise SpatialEvidenceProjectionError("occupied cell is invalid")
|
||||
indices = (
|
||||
_signed_integer(raw_cell.get("x"), "cell x"),
|
||||
_signed_integer(raw_cell.get("y"), "cell y"),
|
||||
_signed_integer(raw_cell.get("z"), "cell z"),
|
||||
)
|
||||
point_map = (
|
||||
(indices[0] + 0.5) * occupied_voxel_size_m,
|
||||
(indices[1] + 0.5) * occupied_voxel_size_m,
|
||||
(indices[2] + 0.5) * occupied_voxel_size_m,
|
||||
)
|
||||
cell_centers.append(list(body_frame.map_point_to_body(point_map)))
|
||||
visuals.append(
|
||||
{
|
||||
"component_id": row.get("component_id"),
|
||||
"state": row.get("state"),
|
||||
"motion": row.get("motion"),
|
||||
"centroid_body_xyz_m": list(centroid_body),
|
||||
"cell_centers_body_xyz_m": cell_centers,
|
||||
"assessment": row.get("assessment"),
|
||||
}
|
||||
)
|
||||
return visuals
|
||||
|
||||
|
||||
def _finite_vector3(value: Sequence[object], label: str) -> tuple[float, float, float]:
|
||||
if len(value) != 3:
|
||||
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||
return (
|
||||
_finite_float(value[0], label),
|
||||
_finite_float(value[1], label),
|
||||
_finite_float(value[2], label),
|
||||
)
|
||||
|
||||
|
||||
def _finite_float(value: object, label: str) -> float:
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||
parsed = float(value)
|
||||
if not math.isfinite(parsed):
|
||||
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||
return parsed
|
||||
|
||||
|
||||
def _signed_integer(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise SpatialEvidenceProjectionError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SpatialEvidenceProjectionError",
|
||||
"project_metric_obstacles_to_body",
|
||||
"sample_points_in_body_frame",
|
||||
]
|
||||
@@ -38,6 +38,10 @@ from .geometry import RecordedGeometryStore
|
||||
from .geometry_replay import GeometryReplayResult, read_geometry_replay_result
|
||||
from .providers import SourcePacket
|
||||
from .recorded_source import RecordedRavnoves00Source, ReplayPacing
|
||||
from .spatial_evidence import (
|
||||
project_metric_obstacles_to_body,
|
||||
sample_points_in_body_frame,
|
||||
)
|
||||
from .temporal_replay import TemporalReplayResult, read_temporal_replay_result
|
||||
from .threat import (
|
||||
DEFAULT_REPLAY_THREAT_PROFILE_PATH,
|
||||
@@ -654,49 +658,24 @@ def _visual_frame(
|
||||
points = store.current_points(packet)
|
||||
if points is None:
|
||||
raise ThreatReplayError("visual frame has no current point cloud")
|
||||
basis = np.asarray(body_frame.basis_map_from_body, dtype=np.float64)
|
||||
origin = np.asarray(body_frame.origin_map_xyz_m, dtype=np.float64)
|
||||
points_body = (points - origin) @ basis
|
||||
stride = max(1, math.ceil(points_body.shape[0] / VISUAL_POINT_LIMIT))
|
||||
sampled = points_body[::stride][:VISUAL_POINT_LIMIT]
|
||||
metric_visuals = []
|
||||
for row in metric_rows:
|
||||
centroid = row.get("centroid_map_xyz_m")
|
||||
cells = row.get("cells")
|
||||
if not isinstance(centroid, list) or not isinstance(cells, list):
|
||||
continue
|
||||
centroid_body = body_frame.map_point_to_body(
|
||||
(float(centroid[0]), float(centroid[1]), float(centroid[2]))
|
||||
)
|
||||
cell_centers = []
|
||||
for raw_cell in cells:
|
||||
cell = _object(raw_cell, "visual occupied cell")
|
||||
point_map = tuple(
|
||||
(_signed_integer(cell.get(key), f"cell {key}") + 0.5)
|
||||
* profile.corridor.occupied_voxel_size_m
|
||||
for key in ("x", "y", "z")
|
||||
)
|
||||
cell_centers.append(
|
||||
list(body_frame.map_point_to_body((point_map[0], point_map[1], point_map[2])))
|
||||
)
|
||||
metric_visuals.append(
|
||||
{
|
||||
"component_id": row["component_id"],
|
||||
"state": row["state"],
|
||||
"motion": row["motion"],
|
||||
"centroid_body_xyz_m": list(centroid_body),
|
||||
"cell_centers_body_xyz_m": cell_centers,
|
||||
"assessment": row["assessment"],
|
||||
}
|
||||
)
|
||||
sampled, source_count = sample_points_in_body_frame(
|
||||
points,
|
||||
body_frame,
|
||||
point_limit=VISUAL_POINT_LIMIT,
|
||||
)
|
||||
metric_visuals = project_metric_obstacles_to_body(
|
||||
metric_rows,
|
||||
body_frame,
|
||||
occupied_voxel_size_m=profile.corridor.occupied_voxel_size_m,
|
||||
)
|
||||
return {
|
||||
"schema_version": THREAT_REPLAY_VISUAL_SCHEMA_V2,
|
||||
"sequence": packet.envelope.sequence,
|
||||
"frame_id": packet.envelope.frame_id,
|
||||
"source_time_ns": packet.envelope.timestamps.source_ns,
|
||||
"point_cloud_body_xyz_m": np.round(sampled, 6).tolist(),
|
||||
"point_cloud_source_count": int(points.shape[0]),
|
||||
"point_cloud_sample_count": int(sampled.shape[0]),
|
||||
"point_cloud_body_xyz_m": sampled,
|
||||
"point_cloud_source_count": source_count,
|
||||
"point_cloud_sample_count": len(sampled),
|
||||
"point_cloud_layer": "current-increment",
|
||||
"rolling_map_component_count": sum(
|
||||
row.get("state") == TemporalState.RETAINED.value for row in metric_rows
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Bounded recorded-realtime projection of a sealed replay threat ledger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Final
|
||||
|
||||
from .geometry import RecordedGeometryStore
|
||||
from .spatial_evidence import (
|
||||
project_metric_obstacles_to_body,
|
||||
sample_points_in_body_frame,
|
||||
)
|
||||
from .threat import (
|
||||
DEFAULT_REPLAY_THREAT_PROFILE_PATH,
|
||||
RecordedReplayBodyFrameResolver,
|
||||
load_replay_threat_profile,
|
||||
)
|
||||
from .threat_replay import (
|
||||
THREAT_REPLAY_FRAME_SCHEMA,
|
||||
THREAT_REPLAY_FRAME_SCHEMA_V2,
|
||||
ThreatReplayResult,
|
||||
)
|
||||
|
||||
RECORDED_SPATIAL_TIMELINE_SCHEMA: Final = "missioncore.recorded-spatial-evidence-timeline/v1"
|
||||
RECORDED_SPATIAL_CHUNK_SCHEMA: Final = "missioncore.recorded-spatial-evidence-chunk/v1"
|
||||
RECORDED_SPATIAL_FRAME_SCHEMA: Final = "missioncore.recorded-spatial-evidence-frame/v1"
|
||||
RECORDED_SPATIAL_POINT_LIMIT: Final = 2_000
|
||||
RECORDED_SPATIAL_MAX_CHUNK_FRAMES: Final = 24
|
||||
_EXPECTED_FRAME_COUNT: Final = 4_489
|
||||
_SOURCE_TIME = re.compile(rb'"source_time_ns":([0-9]+)')
|
||||
|
||||
|
||||
class RecordedThreatTimelineError(RuntimeError):
|
||||
"""A bounded timeline projection escaped its sealed result or source."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedThreatTimelineIndex:
|
||||
offsets: tuple[int, ...]
|
||||
source_times_ns: tuple[int, ...]
|
||||
|
||||
|
||||
class RecordedThreatTimeline:
|
||||
"""Read bounded spatial chunks without materializing the full ledger in memory."""
|
||||
|
||||
def __init__(self, *, repository_root: Path, result: ThreatReplayResult) -> None:
|
||||
self.repository_root = repository_root.resolve(strict=True)
|
||||
self.result = result
|
||||
self.frames_path = (result.result_root / "frames.jsonl").resolve(strict=True)
|
||||
if self.frames_path.is_symlink() or self.frames_path.parent != result.result_root:
|
||||
raise RecordedThreatTimelineError("recorded timeline frame ledger is invalid")
|
||||
self.profile = load_replay_threat_profile(
|
||||
self.repository_root / DEFAULT_REPLAY_THREAT_PROFILE_PATH
|
||||
)
|
||||
identity = result.manifest.get("identity")
|
||||
if not isinstance(identity, dict):
|
||||
raise RecordedThreatTimelineError("recorded timeline identity is missing")
|
||||
expected_identity = {
|
||||
"profile_id": self.profile.profile_id,
|
||||
"profile_sha256": self.profile.profile_sha256,
|
||||
"source_id": self.profile.source_id,
|
||||
"source_session_id": self.profile.session_id,
|
||||
"source_pack_id": self.profile.source_pack_id,
|
||||
"source_pack_sha256": self.profile.source_pack_sha256,
|
||||
}
|
||||
if any(identity.get(key) != value for key, value in expected_identity.items()):
|
||||
raise RecordedThreatTimelineError("recorded timeline escaped the threat profile")
|
||||
self.store = RecordedGeometryStore.from_repository(self.repository_root)
|
||||
if (
|
||||
self.store.profile.source_pack_id != self.profile.source_pack_id
|
||||
or self.store.profile.source_pack_sha256 != self.profile.source_pack_sha256
|
||||
or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT
|
||||
):
|
||||
raise RecordedThreatTimelineError("recorded timeline geometry identity changed")
|
||||
self.body_frames = RecordedReplayBodyFrameResolver(
|
||||
self.store,
|
||||
profile=self.profile.body_frame,
|
||||
)
|
||||
self.index = _index_frame_ledger(self.frames_path)
|
||||
self._lock = RLock()
|
||||
|
||||
def metadata(self) -> dict[str, object]:
|
||||
times = self.index.source_times_ns
|
||||
intervals = [(current - previous) / 1_000_000_000 for previous, current in pairwise(times)]
|
||||
nominal_interval = statistics.median(intervals)
|
||||
if not math.isfinite(nominal_interval) or nominal_interval <= 0:
|
||||
raise RecordedThreatTimelineError("recorded timeline cadence is invalid")
|
||||
return {
|
||||
"schema_version": RECORDED_SPATIAL_TIMELINE_SCHEMA,
|
||||
"result_id": self.result.result_id,
|
||||
"recorded_source": {
|
||||
"session_id": self.profile.session_id,
|
||||
"source_id": self.profile.source_id,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
},
|
||||
"frame_count": len(times),
|
||||
"frame_times_ns": list(times),
|
||||
"timeline_start_seconds": times[0] / 1_000_000_000,
|
||||
"timeline_end_seconds": times[-1] / 1_000_000_000,
|
||||
"nominal_frame_interval_seconds": nominal_interval,
|
||||
"nominal_rate_hz": 1 / nominal_interval,
|
||||
"max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES,
|
||||
"point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT,
|
||||
"image_width": 800,
|
||||
"image_height": 600,
|
||||
"rig": {
|
||||
"length_m": self.profile.rig.body_length_m,
|
||||
"width_m": self.profile.rig.body_width_m,
|
||||
"nominal_sensor_height_m": self.profile.rig.nominal_sensor_height_m,
|
||||
},
|
||||
"corridor": {
|
||||
"forward_length_m": self.profile.corridor.forward_length_m,
|
||||
"rear_margin_m": self.profile.corridor.rear_margin_m,
|
||||
"half_width_m": (
|
||||
self.profile.rig.body_width_m / 2 + self.profile.corridor.lateral_clearance_m
|
||||
),
|
||||
"prediction_horizon_seconds": (self.profile.corridor.prediction_horizon_seconds),
|
||||
},
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"access": "read-only-bounded-recorded-replay",
|
||||
}
|
||||
|
||||
def chunk(self, *, start_sequence: int, frame_count: int) -> dict[str, object]:
|
||||
if not 0 <= start_sequence < len(self.index.offsets):
|
||||
raise RecordedThreatTimelineError("recorded timeline chunk start is invalid")
|
||||
if not 1 <= frame_count <= RECORDED_SPATIAL_MAX_CHUNK_FRAMES:
|
||||
raise RecordedThreatTimelineError("recorded timeline chunk size is invalid")
|
||||
stop = min(len(self.index.offsets), start_sequence + frame_count)
|
||||
with self._lock:
|
||||
frames = [self._project_frame(sequence) for sequence in range(start_sequence, stop)]
|
||||
return {
|
||||
"schema_version": RECORDED_SPATIAL_CHUNK_SCHEMA,
|
||||
"result_id": self.result.result_id,
|
||||
"start_sequence": start_sequence,
|
||||
"frame_count": len(frames),
|
||||
"next_sequence": stop if stop < len(self.index.offsets) else None,
|
||||
"frames": frames,
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
"access": "read-only-bounded-recorded-replay",
|
||||
}
|
||||
|
||||
def _project_frame(self, sequence: int) -> dict[str, object]:
|
||||
row = _read_frame_at(self.frames_path, self.index, sequence)
|
||||
frame_id = row.get("frame_id")
|
||||
if not isinstance(frame_id, str) or not frame_id:
|
||||
raise RecordedThreatTimelineError("recorded timeline frame identity is invalid")
|
||||
body_frame = self.body_frames.body_frame_for_frame(frame_id)
|
||||
body_frame_declared = row.get("body_frame_available")
|
||||
if not isinstance(body_frame_declared, bool) or body_frame_declared is not (
|
||||
body_frame is not None
|
||||
):
|
||||
raise RecordedThreatTimelineError("recorded timeline body-frame binding changed")
|
||||
source_available = row.get("source_available")
|
||||
if not isinstance(source_available, bool):
|
||||
raise RecordedThreatTimelineError("recorded timeline source state is invalid")
|
||||
point_cloud: list[list[float]] = []
|
||||
point_source_count = 0
|
||||
metric_visuals: list[dict[str, object]] = []
|
||||
if body_frame is not None:
|
||||
points = self.store.current_points_for_frame(sequence)
|
||||
if points is None or not source_available:
|
||||
raise RecordedThreatTimelineError(
|
||||
"recorded timeline current increment binding changed"
|
||||
)
|
||||
point_cloud, point_source_count = sample_points_in_body_frame(
|
||||
points,
|
||||
body_frame,
|
||||
point_limit=RECORDED_SPATIAL_POINT_LIMIT,
|
||||
)
|
||||
metric_visuals = project_metric_obstacles_to_body(
|
||||
_mapping_array(row.get("metric_obstacles"), "metric obstacles"),
|
||||
body_frame,
|
||||
occupied_voxel_size_m=self.profile.corridor.occupied_voxel_size_m,
|
||||
)
|
||||
assessments = _mapping_array(row.get("assessments"), "threat assessments")
|
||||
camera_proposals = row.get("camera_proposals")
|
||||
if not isinstance(camera_proposals, list):
|
||||
raise RecordedThreatTimelineError("recorded timeline camera proposals are invalid")
|
||||
return {
|
||||
"schema_version": RECORDED_SPATIAL_FRAME_SCHEMA,
|
||||
"sequence": sequence,
|
||||
"frame_id": frame_id,
|
||||
"source_time_ns": self.index.source_times_ns[sequence],
|
||||
"session_seconds": self.index.source_times_ns[sequence] / 1_000_000_000,
|
||||
"source_available": source_available,
|
||||
"spatial_available": body_frame is not None,
|
||||
"point_cloud_body_xyz_m": point_cloud,
|
||||
"point_cloud_source_count": point_source_count,
|
||||
"point_cloud_sample_count": len(point_cloud),
|
||||
"point_cloud_layer": "current-increment",
|
||||
"rolling_map_component_count": sum(
|
||||
item.get("state") == "retained" for item in metric_visuals
|
||||
),
|
||||
"metric_obstacles": metric_visuals,
|
||||
"camera_proposals": copy.deepcopy(camera_proposals),
|
||||
"decision_counts": _decision_counts(assessments),
|
||||
"camera_url": (
|
||||
f"/api/v1/laboratory/m4-threat/results/{self.result.result_id}"
|
||||
f"/timeline/frames/{sequence}/camera"
|
||||
),
|
||||
"ground_truth": False,
|
||||
"authority": "replay-simulated",
|
||||
}
|
||||
|
||||
|
||||
def _index_frame_ledger(path: Path) -> RecordedThreatTimelineIndex:
|
||||
offsets: list[int] = []
|
||||
source_times: list[int] = []
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
offset = handle.tell()
|
||||
line = handle.readline()
|
||||
if not line:
|
||||
break
|
||||
match = _SOURCE_TIME.search(line)
|
||||
if match is None:
|
||||
raise RecordedThreatTimelineError("recorded timeline source time is missing")
|
||||
offsets.append(offset)
|
||||
source_times.append(int(match.group(1)))
|
||||
if len(offsets) != _EXPECTED_FRAME_COUNT:
|
||||
raise RecordedThreatTimelineError("recorded timeline frame count changed")
|
||||
if any(current <= previous for previous, current in pairwise(source_times)):
|
||||
raise RecordedThreatTimelineError("recorded timeline source time is not monotonic")
|
||||
return RecordedThreatTimelineIndex(tuple(offsets), tuple(source_times))
|
||||
|
||||
|
||||
def _read_frame_at(
|
||||
path: Path,
|
||||
index: RecordedThreatTimelineIndex,
|
||||
sequence: int,
|
||||
) -> dict[str, object]:
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(index.offsets[sequence])
|
||||
line = handle.readline()
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise RecordedThreatTimelineError("recorded timeline frame JSON is invalid") from error
|
||||
if (
|
||||
not isinstance(row, dict)
|
||||
or row.get("schema_version")
|
||||
not in {THREAT_REPLAY_FRAME_SCHEMA, THREAT_REPLAY_FRAME_SCHEMA_V2}
|
||||
or row.get("sequence") != sequence
|
||||
or row.get("source_time_ns") != index.source_times_ns[sequence]
|
||||
):
|
||||
raise RecordedThreatTimelineError("recorded timeline frame binding changed")
|
||||
return row
|
||||
|
||||
|
||||
def _mapping_array(value: object, label: str) -> list[dict[str, object]]:
|
||||
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
||||
raise RecordedThreatTimelineError(f"recorded timeline {label} are invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _decision_counts(assessments: list[dict[str, object]]) -> dict[str, int]:
|
||||
result = {"threat": 0, "not-threat": 0, "unknown": 0}
|
||||
for item in assessments:
|
||||
decision = item.get("decision")
|
||||
if not isinstance(decision, str) or decision not in result:
|
||||
raise RecordedThreatTimelineError("recorded timeline decision is invalid")
|
||||
result[decision] += 1
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RECORDED_SPATIAL_CHUNK_SCHEMA",
|
||||
"RECORDED_SPATIAL_FRAME_SCHEMA",
|
||||
"RECORDED_SPATIAL_MAX_CHUNK_FRAMES",
|
||||
"RECORDED_SPATIAL_POINT_LIMIT",
|
||||
"RECORDED_SPATIAL_TIMELINE_SCHEMA",
|
||||
"RecordedThreatTimeline",
|
||||
"RecordedThreatTimelineError",
|
||||
]
|
||||
@@ -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