fix(replay): stream sealed perception overlays
This commit is contained in:
@@ -220,6 +220,7 @@ from .realtime_tracking_qualification import (
|
||||
from .results import (
|
||||
DetectionFrame,
|
||||
ObjectDetection,
|
||||
RecordedPerceptionOverlayArtifact,
|
||||
RecordedPerceptionOverlayError,
|
||||
RecordedPerceptionOverlayStore,
|
||||
RecordedPerceptionResult,
|
||||
@@ -414,6 +415,7 @@ __all__ = [
|
||||
"lidar_field_review_catalog_item",
|
||||
"DetectionFrame",
|
||||
"ObjectDetection",
|
||||
"RecordedPerceptionOverlayArtifact",
|
||||
"RecordedPerceptionOverlayError",
|
||||
"RecordedPerceptionOverlayStore",
|
||||
"RecordedPerceptionResult",
|
||||
|
||||
@@ -22,7 +22,10 @@ from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
from .perception_epoch import validate_recorded_perception_epoch_result
|
||||
from .results import RecordedPerceptionOverlayError
|
||||
from .results import (
|
||||
RecordedPerceptionOverlayArtifact,
|
||||
RecordedPerceptionOverlayError,
|
||||
)
|
||||
|
||||
FUSION_SCHEMA = "missioncore.recorded-calibrated-fusion/v1"
|
||||
FUSION_IDENTITY_SCHEMA = "missioncore.recorded-calibrated-fusion-identity/v1"
|
||||
@@ -188,18 +191,77 @@ class RecordedPerceptionOverlayMux:
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
payload = self.primary.render(
|
||||
materialized = self.materialize(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if not isinstance(materialized, RecordedPerceptionOverlayArtifact):
|
||||
return materialized
|
||||
try:
|
||||
payload = materialized.path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"recorded perception cache became unavailable"
|
||||
) from exc
|
||||
if (
|
||||
len(payload) != materialized.byte_length
|
||||
or hashlib.sha256(payload).hexdigest() != materialized.sha256
|
||||
):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"recorded perception cache changed after validation"
|
||||
)
|
||||
return payload
|
||||
|
||||
def materialize(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> RecordedPerceptionOverlayArtifact | bytes | None:
|
||||
primary_materializer = getattr(self.primary, "materialize", None)
|
||||
payload = (
|
||||
primary_materializer(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if callable(primary_materializer)
|
||||
else self.primary.render(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
)
|
||||
if payload is not None and not isinstance(
|
||||
payload, (bytes, RecordedPerceptionOverlayArtifact)
|
||||
):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"recorded perception provider returned an invalid artifact"
|
||||
)
|
||||
if payload is not None or self.fallback is None:
|
||||
return payload
|
||||
return self.fallback.render(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
fallback_materializer = getattr(self.fallback, "materialize", None)
|
||||
if callable(fallback_materializer):
|
||||
fallback_payload = fallback_materializer(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
else:
|
||||
fallback_payload = self.fallback.render(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if fallback_payload is not None and not isinstance(
|
||||
fallback_payload, (bytes, RecordedPerceptionOverlayArtifact)
|
||||
):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"recorded perception fallback returned an invalid artifact"
|
||||
)
|
||||
return fallback_payload
|
||||
|
||||
def status(self, session_id: str, *, recording_id: str) -> dict[str, Any]:
|
||||
primary_status = getattr(self.primary, "status", None)
|
||||
|
||||
@@ -26,7 +26,10 @@ from k1link.artifacts import write_json_atomic
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
from .results import RecordedPerceptionOverlayError
|
||||
from .results import (
|
||||
RecordedPerceptionOverlayArtifact,
|
||||
RecordedPerceptionOverlayError,
|
||||
)
|
||||
|
||||
RESULT_SCHEMA = "missioncore.e10-integrated-perception-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e10-integrated-perception-identity/v1"
|
||||
@@ -397,6 +400,39 @@ class IntegratedPerceptionOverlayStore:
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
"""Compatibility adapter for in-process consumers that require bytes."""
|
||||
|
||||
artifact = self.materialize(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if artifact is None:
|
||||
return None
|
||||
try:
|
||||
payload = artifact.path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception cache became unavailable"
|
||||
) from exc
|
||||
if (
|
||||
len(payload) != artifact.byte_length
|
||||
or hashlib.sha256(payload).hexdigest() != artifact.sha256
|
||||
):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception cache changed after validation"
|
||||
)
|
||||
return payload
|
||||
|
||||
def materialize(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> RecordedPerceptionOverlayArtifact | None:
|
||||
"""Return a verified file-backed overlay without copying it into heap."""
|
||||
|
||||
if application_id != "nodedc_mission_core_recorded":
|
||||
raise ValueError("integrated perception application id is invalid")
|
||||
if (
|
||||
@@ -414,7 +450,7 @@ class IntegratedPerceptionOverlayStore:
|
||||
key,
|
||||
state="ready",
|
||||
phase="ready",
|
||||
byte_length=len(cached),
|
||||
byte_length=cached.byte_length,
|
||||
)
|
||||
return cached
|
||||
self._set_status(key, state="preparing", phase="queued")
|
||||
@@ -431,7 +467,7 @@ class IntegratedPerceptionOverlayStore:
|
||||
)
|
||||
output = cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
cached = _read_cache(
|
||||
cached = _read_cache_artifact(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=result.result_id,
|
||||
@@ -442,7 +478,7 @@ class IntegratedPerceptionOverlayStore:
|
||||
key,
|
||||
state="ready",
|
||||
phase="ready",
|
||||
byte_length=len(cached),
|
||||
byte_length=cached.byte_length,
|
||||
)
|
||||
return cached
|
||||
self._set_status(key, state="preparing", phase="rendering")
|
||||
@@ -483,7 +519,17 @@ class IntegratedPerceptionOverlayStore:
|
||||
phase="ready",
|
||||
byte_length=len(payload),
|
||||
)
|
||||
return payload
|
||||
published = _read_cache_artifact(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=result.result_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if published is None:
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception cache publication failed"
|
||||
)
|
||||
return published
|
||||
except BaseException:
|
||||
self._set_status(key, state="error", phase="error")
|
||||
raise
|
||||
@@ -556,7 +602,7 @@ class IntegratedPerceptionOverlayStore:
|
||||
self,
|
||||
session_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
) -> RecordedPerceptionOverlayArtifact | None:
|
||||
cache_root = _private_directory(self.cache_root)
|
||||
session_cache = _private_child(cache_root, session_id)
|
||||
admission = self._load_admission(session_cache, session_id)
|
||||
@@ -570,15 +616,15 @@ class IntegratedPerceptionOverlayStore:
|
||||
recording_id,
|
||||
)
|
||||
if recovered is not None:
|
||||
_, payload = recovered
|
||||
return payload
|
||||
_, artifact = recovered
|
||||
return artifact
|
||||
admission = None
|
||||
if admission is None or admission.result_id != latest.result_id:
|
||||
return None
|
||||
result_cache = _private_child(session_cache, admission.result_id)
|
||||
output = result_cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
return _read_cache(
|
||||
return _read_cache_artifact(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=admission.result_id,
|
||||
@@ -631,17 +677,17 @@ class IntegratedPerceptionOverlayStore:
|
||||
session_cache: Path,
|
||||
descriptor: _ResultDescriptor,
|
||||
recording_id: str,
|
||||
) -> tuple[_OverlayAdmission, bytes] | None:
|
||||
) -> tuple[_OverlayAdmission, RecordedPerceptionOverlayArtifact] | None:
|
||||
cache = _private_child(session_cache, descriptor.result_id)
|
||||
output = cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
payload = _read_cache(
|
||||
artifact = _read_cache_artifact(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=descriptor.result_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if payload is None:
|
||||
if artifact is None:
|
||||
return None
|
||||
admission = _OverlayAdmission(
|
||||
session_id=descriptor.session_id,
|
||||
@@ -650,7 +696,7 @@ class IntegratedPerceptionOverlayStore:
|
||||
result_created_at_utc=descriptor.created_at_utc,
|
||||
)
|
||||
self._write_admission_document(session_cache, admission)
|
||||
return admission, payload
|
||||
return admission, artifact
|
||||
|
||||
def _write_admission(self, result: IntegratedPerceptionResult) -> None:
|
||||
descriptor = _read_result_descriptor(result.result_root)
|
||||
@@ -1183,16 +1229,19 @@ def _read_result_descriptor(result_root: Path) -> _ResultDescriptor:
|
||||
)
|
||||
|
||||
|
||||
def _read_cache(
|
||||
def _read_cache_artifact(
|
||||
output: Path,
|
||||
sidecar: Path,
|
||||
*,
|
||||
result_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
) -> RecordedPerceptionOverlayArtifact | None:
|
||||
try:
|
||||
value = _read_object(sidecar, sidecar.parent)
|
||||
payload = output.read_bytes()
|
||||
metadata = _confined_file(output, sidecar.parent)
|
||||
with output.open("rb") as stream:
|
||||
magic = stream.read(4)
|
||||
digest = _sha256(output)
|
||||
except (OSError, SessionIntegrityError):
|
||||
return None
|
||||
if (
|
||||
@@ -1200,12 +1249,16 @@ def _read_cache(
|
||||
or value.get("renderer_version") != OVERLAY_RENDERER_VERSION
|
||||
or value.get("result_id") != result_id
|
||||
or value.get("recording_id") != recording_id
|
||||
or value.get("byte_length") != len(payload)
|
||||
or value.get("sha256") != hashlib.sha256(payload).hexdigest()
|
||||
or not payload.startswith(b"RRF2")
|
||||
or value.get("byte_length") != metadata.st_size
|
||||
or value.get("sha256") != digest
|
||||
or magic != b"RRF2"
|
||||
):
|
||||
return None
|
||||
return payload
|
||||
return RecordedPerceptionOverlayArtifact(
|
||||
path=output.resolve(strict=True),
|
||||
byte_length=metadata.st_size,
|
||||
sha256=digest,
|
||||
)
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
|
||||
@@ -53,6 +53,15 @@ class DetectionFrame:
|
||||
detections: tuple[ObjectDetection, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedPerceptionOverlayArtifact:
|
||||
"""One validated file-backed RRD ready for bounded HTTP streaming."""
|
||||
|
||||
path: Path
|
||||
byte_length: int
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedPerceptionResult:
|
||||
result_id: str
|
||||
|
||||
@@ -15,7 +15,11 @@ from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator,
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from k1link.compute import RecordedPerceptionOverlayError, RecordedPerceptionVideo
|
||||
from k1link.compute import (
|
||||
RecordedPerceptionOverlayArtifact,
|
||||
RecordedPerceptionOverlayError,
|
||||
RecordedPerceptionVideo,
|
||||
)
|
||||
from k1link.sessions import (
|
||||
LayoutConflictError,
|
||||
MaterializedRecording,
|
||||
@@ -899,8 +903,9 @@ def build_session_router(
|
||||
False,
|
||||
False,
|
||||
)
|
||||
materializer = getattr(perception_overlay_provider, "materialize", None)
|
||||
payload = await run_in_threadpool(
|
||||
perception_overlay_provider.render,
|
||||
materializer if callable(materializer) else perception_overlay_provider.render,
|
||||
session_id,
|
||||
application_id=request.application_id,
|
||||
recording_id=request.recording_id,
|
||||
@@ -921,6 +926,32 @@ def build_session_router(
|
||||
) from exc
|
||||
if payload is None:
|
||||
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
||||
if isinstance(payload, RecordedPerceptionOverlayArtifact):
|
||||
try:
|
||||
metadata = payload.path.stat()
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Не удалось подготовить слой распознавания.",
|
||||
) from exc
|
||||
if not metadata.st_size == payload.byte_length:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Не удалось подготовить слой распознавания.",
|
||||
)
|
||||
return FileResponse(
|
||||
path=payload.path,
|
||||
media_type="application/vnd.rerun.rrd",
|
||||
filename="perception.rrd",
|
||||
content_disposition_type="inline",
|
||||
stat_result=metadata,
|
||||
headers={
|
||||
"Cache-Control": "private, no-cache, no-transform",
|
||||
"Content-Length": str(payload.byte_length),
|
||||
"ETag": f'"sha256:{payload.sha256}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="application/vnd.rerun.rrd",
|
||||
|
||||
Reference in New Issue
Block a user