fix(viewer): gate recorded perception readiness

This commit is contained in:
DCCONSTRUCTIONS 2026-07-23 12:27:48 +03:00
parent ee15160862
commit 9f712bf353
6 changed files with 347 additions and 40 deletions

View File

@ -5,6 +5,20 @@ import type { RecordedAdmissionPhase } from "../core/observation/recordedSession
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
export type RecordedPerceptionLoadPhase =
| "idle"
| "loading"
| "ready"
| "unavailable"
| "error";
export interface RecordedPerceptionLoadState {
phase: RecordedPerceptionLoadPhase;
receivedBytes: number;
totalBytes: number | null;
progress: number | null;
message: string;
}
export interface RecordedPerceptionLayers {
enabled: boolean;
@ -67,7 +81,9 @@ export interface RerunViewportProps {
recordedView?: RecordedRerunView;
recordedViewResetGeneration?: 0 | 1;
recordedPerceptionLayers?: RecordedPerceptionLayers;
onPerceptionAvailabilityChange?: (available: boolean) => void;
recordedPerceptionRetryGeneration?: number;
lockPerceptionCameraInteraction?: boolean;
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
}
export interface RecordedRrdArtifactDescriptor {
@ -566,10 +582,12 @@ export async function fetchRecordedPerceptionRrd(
origin,
signal,
fetcher = globalThis.fetch,
onProgress,
}: {
origin: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
onProgress?: (receivedBytes: number, totalBytes: number) => void;
},
): Promise<Uint8Array | null> {
const base = new URL(origin);
@ -609,9 +627,32 @@ export async function fetchRecordedPerceptionRrd(
) {
throw new Error("Invalid recorded perception response");
}
const payload = new Uint8Array(await response.arrayBuffer());
const payload = new Uint8Array(declaredLength);
let receivedBytes = 0;
onProgress?.(receivedBytes, declaredLength);
if (response.body) {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (receivedBytes + value.byteLength > declaredLength) {
await reader.cancel();
throw new Error("Recorded perception response exceeds its declared length");
}
payload.set(value, receivedBytes);
receivedBytes += value.byteLength;
onProgress?.(receivedBytes, declaredLength);
}
} else {
const fallback = new Uint8Array(await response.arrayBuffer());
if (fallback.byteLength <= declaredLength) {
payload.set(fallback);
receivedBytes = fallback.byteLength;
onProgress?.(receivedBytes, declaredLength);
}
}
if (
payload.byteLength !== declaredLength ||
receivedBytes !== declaredLength ||
payload[0] !== 0x52 ||
payload[1] !== 0x52 ||
payload[2] !== 0x46 ||
@ -644,7 +685,9 @@ export function RerunViewport({
segmentation: false,
cuboids3d: false,
},
onPerceptionAvailabilityChange,
recordedPerceptionRetryGeneration = 0,
lockPerceptionCameraInteraction = false,
onPerceptionLoadChange,
}: RerunViewportProps) {
const hostRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
@ -1189,7 +1232,7 @@ export function RerunViewport({
}, [recordedPerceptionUrl]);
useEffect(() => {
if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) return;
if (!recordedPerceptionUrl) return;
const active = perceptionChannelRef.current;
const identity = recordedIdentityRef.current;
if (
@ -1199,16 +1242,51 @@ export function RerunViewport({
!active.channel.ready
) return;
if (loadedPerceptionChannelRef.current === active) {
onPerceptionAvailabilityChange?.(true);
onPerceptionLoadChange?.({
phase: "ready",
receivedBytes: 0,
totalBytes: null,
progress: 1,
message: "AI-слои готовы.",
});
return;
}
const abort = new AbortController();
let lastReportedPercent = -1;
onPerceptionLoadChange?.({
phase: "loading",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "Сервер готовит AI-слои.",
});
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
origin: window.location.origin,
signal: abort.signal,
onProgress: (receivedBytes, totalBytes) => {
const progress = totalBytes > 0 ? receivedBytes / totalBytes : null;
const percent = progress === null ? -1 : Math.floor(progress * 100);
if (percent === lastReportedPercent && receivedBytes !== totalBytes) return;
lastReportedPercent = percent;
onPerceptionLoadChange?.({
phase: "loading",
receivedBytes,
totalBytes,
progress,
message: progress === null
? "Загружаем AI-слои."
: `Загружаем AI-слои · ${Math.round(progress * 100)}%`,
});
},
}).then((payload) => {
if (payload === null) {
onPerceptionAvailabilityChange?.(false);
onPerceptionLoadChange?.({
phase: "unavailable",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "Для этой сессии AI-слои не опубликованы.",
});
return;
}
if (
@ -1221,16 +1299,31 @@ export function RerunViewport({
}
active.channel.send_rrd(payload);
loadedPerceptionChannelRef.current = active;
onPerceptionAvailabilityChange?.(true);
}).catch(() => {
onPerceptionAvailabilityChange?.(false);
onPerceptionLoadChange?.({
phase: "ready",
receivedBytes: payload.byteLength,
totalBytes: payload.byteLength,
progress: 1,
message: "AI-слои готовы.",
});
}).catch((error: unknown) => {
if (abort.signal.aborted) return;
onPerceptionLoadChange?.({
phase: "error",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: error instanceof Error
? "AI-слои не загрузились. Можно повторить."
: "AI-слои недоступны.",
});
// The base recording remains available when no admitted perception layer exists.
});
return () => abort.abort();
}, [
onPerceptionAvailabilityChange,
onPerceptionLoadChange,
perceptionChannelRevision,
recordedPerceptionLayers.enabled,
recordedPerceptionRetryGeneration,
recordedPerceptionUrl,
]);
@ -1292,6 +1385,13 @@ export function RerunViewport({
className="rerun-viewport__canvas"
data-presented={recordedArtifact === null || presentationStatus === "ready" ? "true" : "false"}
/>
{lockPerceptionCameraInteraction && presentationStatus === "ready" ? (
<div
className="rerun-viewport__camera-lock"
aria-hidden="true"
title="Операторское видео зафиксировано"
/>
) : null}
{presentationStatus === "loading" ? (
<div className="rerun-viewport__notice" role="status">
<span className="busy-indicator" aria-hidden="true" />

View File

@ -39,6 +39,39 @@
border-radius: 999px;
}
.spatial-toolbar__perception-status,
.spatial-toolbar__perception-retry {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.4rem;
border: 1px solid rgb(255 255 255 / 0.07);
border-radius: 999px;
background: rgb(255 255 255 / 0.035);
padding: 0.42rem 0.62rem;
color: var(--nodedc-text-muted);
font: inherit;
font-size: 0.58rem;
white-space: nowrap;
}
.spatial-toolbar__perception-status .busy-indicator {
width: 0.72rem;
height: 0.72rem;
flex-basis: 0.72rem;
}
.spatial-toolbar__perception-retry {
color: var(--nodedc-text-primary);
cursor: pointer;
}
.spatial-toolbar__perception-retry:hover,
.spatial-toolbar__perception-retry:focus-visible {
background: rgb(255 255 255 / 0.09);
outline: none;
}
.spatial-viewport-shell {
position: relative;
min-width: 0;
@ -95,6 +128,17 @@
height: 100% !important;
}
.rerun-viewport__camera-lock {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
left: 0;
width: calc(46% - 2px);
cursor: default;
touch-action: none;
}
.rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas {
visibility: hidden;

View File

@ -45,6 +45,7 @@ import {
type RerunPlaybackState,
type RerunSelection,
type RerunViewportStatus,
type RecordedPerceptionLoadState,
} from "../components/RerunViewport";
import {
capabilityStatusLabel,
@ -307,9 +308,14 @@ function SpatialWorkspace({
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
const [perceptionAvailability, setPerceptionAvailability] = useState<
"unknown" | "available" | "unavailable"
>("unknown");
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false);
@ -335,7 +341,9 @@ function SpatialWorkspace({
const mediaSources = observationSources.filter(
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
);
const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable";
const recordedPerceptionSupported =
recordedSource && perceptionLoad.phase !== "unavailable";
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready";
const recordedPerceptionEnabled =
showDetections2d || showSegmentation || showCuboids3d;
// The native recorded camera remains the authoritative original. Only 2D
@ -421,9 +429,9 @@ function SpatialWorkspace({
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
const onPerceptionAvailabilityChange = useCallback((available: boolean) => {
setPerceptionAvailability(available ? "available" : "unavailable");
if (!available) {
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
setPerceptionLoad(next);
if (next.phase === "unavailable" || next.phase === "error") {
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
@ -431,12 +439,19 @@ function SpatialWorkspace({
}, []);
useEffect(() => {
setPerceptionAvailability("unknown");
setPerceptionLoad({
phase: recordedSource ? "loading" : "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
});
setPerceptionRetryGeneration(0);
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
setRecordedViewResetGeneration(0);
}, [sourceUrl]);
}, [recordedSource, sourceUrl]);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
@ -445,7 +460,13 @@ function SpatialWorkspace({
setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
setPerceptionAvailability("unknown");
setPerceptionLoad({
phase: "idle",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "",
});
setShowDetections2d(false);
setShowSegmentation(false);
setShowCuboids3d(false);
@ -507,6 +528,7 @@ function SpatialWorkspace({
variant={detections2dActive ? "primary" : "secondary"}
icon={<Icon name="target" />}
aria-pressed={detections2dActive}
disabled={recordedSource && !recordedPerceptionReady}
onClick={() => recordedSource
? setShowDetections2d((current) => !current)
: onLivePerceptionLayersChange({
@ -521,6 +543,7 @@ function SpatialWorkspace({
variant={segmentationActive ? "primary" : "secondary"}
icon={<Icon name="image" />}
aria-pressed={segmentationActive}
disabled={recordedSource && !recordedPerceptionReady}
onClick={() => recordedSource
? setShowSegmentation((current) => !current)
: onLivePerceptionLayersChange({
@ -535,6 +558,7 @@ function SpatialWorkspace({
variant={cuboids3dActive ? "primary" : "secondary"}
icon={<Icon name="apps" />}
aria-pressed={cuboids3dActive}
disabled={recordedSource && !recordedPerceptionReady}
onClick={() => recordedSource
? setShowCuboids3d((current) => !current)
: onLivePerceptionLayersChange({
@ -546,6 +570,22 @@ function SpatialWorkspace({
</Button>
</div>
) : null}
{recordedSource && perceptionLoad.phase === "loading" ? (
<div className="spatial-toolbar__perception-status" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
</div>
) : null}
{recordedSource && perceptionLoad.phase === "error" ? (
<button
type="button"
className="spatial-toolbar__perception-retry"
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
>
<Icon name="refresh" size={14} />
Повторить AI
</button>
) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
<Button
size="compact"
@ -591,12 +631,17 @@ function SpatialWorkspace({
sceneSettings={sceneSettings}
recordedViewResetGeneration={recordedViewResetGeneration}
recordedPerceptionLayers={{
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
enabled:
recordedPerceptionSupported &&
recordedPerceptionReady &&
recordedPerceptionEnabled,
detections2d: showDetections2d,
segmentation: showSegmentation,
cuboids3d: showCuboids3d,
}}
onPerceptionAvailabilityChange={onPerceptionAvailabilityChange}
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
lockPerceptionCameraInteraction={unifiedPerception}
onPerceptionLoadChange={onPerceptionLoadChange}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}

View File

@ -375,11 +375,15 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/perception.rrd",
);
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const progress = [];
const result = await fetchRecordedPerceptionRrd(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
onProgress: (receivedBytes, totalBytes) => {
progress.push([receivedBytes, totalBytes]);
},
fetcher: async (_input, init) => {
assert.deepEqual(JSON.parse(String(init.body)), {
application_id: "nodedc_mission_core_recorded",
@ -396,6 +400,8 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
},
);
assert.deepEqual([...result], [...payload]);
assert.deepEqual(progress[0], [0, payload.byteLength]);
assert.deepEqual(progress.at(-1), [payload.byteLength, payload.byteLength]);
const absent = await fetchRecordedPerceptionRrd(
endpoint,

View File

@ -105,16 +105,10 @@ class _CuboidPresentationState:
colors: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, list[str]] | None:
accepted = [
item
for item in objects
if str(item.get("cuboid_status", "")).startswith("accepted-")
item for item in objects if str(item.get("cuboid_status", "")).startswith("accepted-")
]
if not (
len(accepted)
== len(centers)
== len(half_sizes)
== len(quaternions)
== len(colors)
len(accepted) == len(centers) == len(half_sizes) == len(quaternions) == len(colors)
):
raise RecordedPerceptionOverlayError(
"integrated perception cuboid presentation arrays are inconsistent"
@ -343,6 +337,8 @@ class IntegratedPerceptionOverlayStore:
self.cache_root = cache_root.expanduser().absolute()
self.ffmpeg_path = ffmpeg_path.expanduser().absolute()
self._lock = threading.Lock()
self._catalog_signature: tuple[Any, ...] | None = None
self._latest_by_session: dict[str, IntegratedPerceptionResult | None] = {}
def render(
self,
@ -402,10 +398,21 @@ class IntegratedPerceptionOverlayStore:
return payload
def _latest(self, session_id: str) -> IntegratedPerceptionResult | None:
signature = _directory_catalog_signature(
self.jobs_root,
self.results_root,
self.lidar_packs_root,
)
if signature != self._catalog_signature:
self._catalog_signature = signature
self._latest_by_session.clear()
if session_id in self._latest_by_session:
return self._latest_by_session[session_id]
try:
jobs = sorted(self.jobs_root.iterdir())
candidates = sorted(self.results_root.iterdir())
except FileNotFoundError:
self._latest_by_session[session_id] = None
return None
if len(jobs) > MAX_SCAN or len(candidates) > MAX_SCAN:
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
@ -436,8 +443,57 @@ class IntegratedPerceptionOverlayStore:
):
matches.append(value)
if not matches:
self._latest_by_session[session_id] = None
return None
return max(matches, key=lambda value: (value.created_at_utc, value.result_id))
latest = max(matches, key=lambda value: (value.created_at_utc, value.result_id))
self._latest_by_session[session_id] = latest
return latest
def _directory_catalog_signature(*roots: Path) -> tuple[Any, ...]:
"""Track immutable lab catalogs without rehashing every large artifact per request.
Accepted result directories are append-only. Their directory mtimes still
change when an atomic manifest replacement occurs, while a new experiment
changes the parent listing. This cheap signature therefore invalidates the
process-local validation memo without weakening the first full integrity
validation of every catalog generation.
"""
signature: list[Any] = []
for root in roots:
try:
root_stat = root.stat()
entries = sorted(root.iterdir(), key=lambda path: path.name)
except FileNotFoundError:
signature.append((str(root), None))
continue
if len(entries) > MAX_SCAN:
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
entry_signature = []
for entry in entries:
try:
metadata = entry.lstat()
except FileNotFoundError:
# A concurrent atomic publication will alter the parent mtime
# and be observed on the next request.
continue
entry_signature.append(
(
entry.name,
metadata.st_mode,
metadata.st_size,
metadata.st_mtime_ns,
)
)
signature.append(
(
str(root),
root_stat.st_mtime_ns,
tuple(entry_signature),
)
)
return tuple(signature)
def _render(
@ -630,9 +686,7 @@ def _camera_proxy_file(
os.close(descriptor)
path = Path(name)
path.unlink(missing_ok=True)
frame_filter = (
f"select=between(n\\,{start_frame_index}\\,{end_frame_index}),setpts=N/(10*TB)"
)
frame_filter = f"select=between(n\\,{start_frame_index}\\,{end_frame_index}),setpts=N/(10*TB)"
try:
completed = subprocess.run(
[
@ -900,9 +954,7 @@ def _canonical_json(value: object) -> bytes:
def _finite(value: object) -> TypeGuard[int | float]:
return (
isinstance(value, int | float)
and not isinstance(value, bool)
and bool(np.isfinite(value))
isinstance(value, int | float) and not isinstance(value, bool) and bool(np.isfinite(value))
)

View File

@ -3,10 +3,16 @@ from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from k1link.compute.integrated_perception import _CuboidPresentationState
import k1link.compute.integrated_perception as integrated_module
from k1link.compute.integrated_perception import (
IntegratedPerceptionOverlayStore,
_CuboidPresentationState,
)
def _worker_modules() -> tuple[object, object]:
@ -35,6 +41,60 @@ def _worker_modules() -> tuple[object, object]:
sys.path.pop(0)
def test_integrated_overlay_catalog_validation_is_memoized_until_publication(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs_root = tmp_path / "jobs"
results_root = tmp_path / "results"
lidar_packs_root = tmp_path / "lidar-packs"
cache_root = tmp_path / "cache"
for root in (jobs_root, results_root, lidar_packs_root):
root.mkdir()
job_root = jobs_root / "job-1"
job_root.mkdir()
first_result = results_root / f"e10-integrated-perception-{'a' * 64}"
first_result.mkdir()
validation_calls = {"job": 0, "result": 0}
job = SimpleNamespace(session_id="session-1")
def validate_job(_root: Path) -> object:
validation_calls["job"] += 1
return job
def validate_result(_job_root: Path, candidate: Path, _packs: Path) -> object:
validation_calls["result"] += 1
return SimpleNamespace(
accepted=True,
publication_scope="recorded-integrated-realtime-qualification-only",
created_at_utc=candidate.name,
result_id=candidate.name,
)
monkeypatch.setattr(integrated_module, "validate_camera_compute_job", validate_job)
monkeypatch.setattr(
integrated_module,
"validate_integrated_perception_result",
validate_result,
)
store = IntegratedPerceptionOverlayStore(
jobs_root=jobs_root,
results_root=results_root,
lidar_packs_root=lidar_packs_root,
cache_root=cache_root,
ffmpeg_path=tmp_path / "ffmpeg",
)
assert store._latest("session-1") is not None
assert store._latest("session-1") is not None
assert validation_calls == {"job": 1, "result": 1}
(results_root / f"e10-integrated-perception-{'b' * 64}").mkdir()
assert store._latest("session-1") is not None
assert validation_calls == {"job": 2, "result": 3}
def test_e10_profile_pins_integrated_realtime_budget() -> None:
_fusion, runner = _worker_modules()
profile_path = (