fix(lab): enforce canonical replay runtime
This commit is contained in:
@@ -37,6 +37,7 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_frame
|
||||
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
||||
from k1link.viewer.recorded import (
|
||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||
@@ -824,6 +825,76 @@ def build_session_router(
|
||||
**response_kwargs,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
|
||||
)
|
||||
async def get_observation_session_canonical_lab_spatial_frame(
|
||||
session_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
|
||||
) -> JSONResponse:
|
||||
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
||||
|
||||
The camera timeline owns playback. Spatial evidence is sampled from
|
||||
the same immutable recording instead of starting a second Rerun clock.
|
||||
"""
|
||||
|
||||
if SAFE_SHA256.fullmatch(generation) is None:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Поколение spatial-записи не совпадает.",
|
||||
)
|
||||
if recording_preparation_manager is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис canonical LAB spatial playback не настроен.",
|
||||
)
|
||||
snapshot = recording_preparation_manager.status(session_id)
|
||||
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Запись canonical LAB ещё не подготовлена.",
|
||||
)
|
||||
_require_matching_recording_generation(snapshot.recording.sha256, generation)
|
||||
pinned = recording_preparation_manager.pin_ready(
|
||||
session_id,
|
||||
preparation_id=snapshot.preparation_id,
|
||||
)
|
||||
if pinned is None:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Подготовленная spatial-запись была заменена.",
|
||||
)
|
||||
pinned_snapshot, release_recording = pinned
|
||||
try:
|
||||
recording = pinned_snapshot.recording
|
||||
if recording is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Подготовленная spatial-запись недоступна.",
|
||||
)
|
||||
payload = await run_in_threadpool(
|
||||
canonical_lab_spatial_frame,
|
||||
recording.path,
|
||||
generation,
|
||||
time_ns,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Canonical LAB spatial frame не прошёл проверку.",
|
||||
) from exc
|
||||
finally:
|
||||
release_recording()
|
||||
return JSONResponse(
|
||||
payload,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{generation}:{payload["source_time_ns"]}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
|
||||
async def get_observation_session_blueprint(
|
||||
session_id: str,
|
||||
|
||||
@@ -11,8 +11,9 @@ from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||
from k1link.laboratory.evidence_report import (
|
||||
@@ -262,9 +263,113 @@ def _build_vegetation_lab_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/{result_id}/route-tgs-anchor/{source_sequence}")
|
||||
def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse:
|
||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||
manifest = _read_verified(candidate, definition)
|
||||
review = manifest.get("route_review")
|
||||
cases = review.get("cases") if isinstance(review, dict) else None
|
||||
if (
|
||||
not isinstance(cases, list)
|
||||
or review.get("source_id") != "RAVNOVES004TREE"
|
||||
or review.get("session_id") != "20260828T130511Z_viewer_live"
|
||||
or not any(
|
||||
isinstance(item, dict) and item.get("source_sequence") == source_sequence
|
||||
for item in cases
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
|
||||
artifacts = manifest.get("artifacts")
|
||||
descriptor = next(
|
||||
(
|
||||
item
|
||||
for item in artifacts if isinstance(item, dict)
|
||||
and item.get("role") == "mixed-route-tgs-evidence"
|
||||
and item.get("path") == "proofs/tgs-evidence.npz"
|
||||
and item.get("media_type") == "application/x-npz"
|
||||
),
|
||||
None,
|
||||
) if isinstance(artifacts, list) else None
|
||||
if descriptor is None:
|
||||
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
|
||||
try:
|
||||
payload = _route_tgs_anchor_payload(
|
||||
candidate / "proofs" / "tgs-evidence.npz",
|
||||
source_sequence,
|
||||
)
|
||||
except (KeyError, OSError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Route TGS anchor failed verification",
|
||||
) from None
|
||||
return JSONResponse(
|
||||
payload,
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{descriptor.get("sha256", "")}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
|
||||
before = path.stat()
|
||||
with np.load(path, allow_pickle=False) as archive:
|
||||
source_indices = archive["source_frame_indices"]
|
||||
offsets = archive["current_increment_point_offsets"]
|
||||
points = archive["current_increment_points_xyz_m"]
|
||||
centers = archive["costmap_cell_centers_xy_m"]
|
||||
states = archive["causal_rolling_1s_costmap_states"]
|
||||
z_bounds = archive["causal_rolling_1s_costmap_z_bounds_m"]
|
||||
if (
|
||||
source_indices.shape != (10,)
|
||||
or offsets.shape != (11,)
|
||||
or points.ndim != 2
|
||||
or points.shape[1] != 3
|
||||
or centers.shape != (2244, 2)
|
||||
or states.shape != (10, 2244)
|
||||
or z_bounds.shape != (10, 2244, 2)
|
||||
):
|
||||
raise ValueError("Route TGS evidence shape changed")
|
||||
matches = np.flatnonzero(source_indices == source_sequence - 1)
|
||||
if matches.shape != (1,):
|
||||
raise ValueError("Route TGS source sequence changed")
|
||||
slot = int(matches[0])
|
||||
start = int(offsets[slot])
|
||||
end = int(offsets[slot + 1])
|
||||
if not 0 <= start <= end <= points.shape[0]:
|
||||
raise ValueError("Route TGS point offsets changed")
|
||||
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
|
||||
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
|
||||
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
|
||||
if not np.isfinite(selected_points).all() or not np.isin(selected_states, [0, 1, 2, 3]).all():
|
||||
raise ValueError("Route TGS payload changed")
|
||||
result = {
|
||||
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
|
||||
"source_sequence": source_sequence,
|
||||
"slot": slot,
|
||||
"current_points_xyz_m": selected_points.astype(float).tolist(),
|
||||
"costmap": {
|
||||
"cell_size_m": 0.45,
|
||||
"centers_xy_m": centers.astype(float).tolist(),
|
||||
"state_codes": selected_states.astype(int).tolist(),
|
||||
"z_bounds_m": [
|
||||
[
|
||||
float(row[0]) if np.isfinite(row[0]) else None,
|
||||
float(row[1]) if np.isfinite(row[1]) else None,
|
||||
]
|
||||
for row in selected_z_bounds
|
||||
],
|
||||
},
|
||||
}
|
||||
after = path.stat()
|
||||
if before.st_size != after.st_size or before.st_mtime_ns != after.st_mtime_ns:
|
||||
raise ValueError("Route TGS evidence changed during read")
|
||||
return result
|
||||
|
||||
|
||||
def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
|
||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user