fix(viewer): gate recorded perception readiness
This commit is contained in:
parent
ee15160862
commit
9f712bf353
|
|
@ -5,6 +5,20 @@ import type { RecordedAdmissionPhase } from "../core/observation/recordedSession
|
||||||
|
|
||||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||||
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
|
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 {
|
export interface RecordedPerceptionLayers {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -67,7 +81,9 @@ export interface RerunViewportProps {
|
||||||
recordedView?: RecordedRerunView;
|
recordedView?: RecordedRerunView;
|
||||||
recordedViewResetGeneration?: 0 | 1;
|
recordedViewResetGeneration?: 0 | 1;
|
||||||
recordedPerceptionLayers?: RecordedPerceptionLayers;
|
recordedPerceptionLayers?: RecordedPerceptionLayers;
|
||||||
onPerceptionAvailabilityChange?: (available: boolean) => void;
|
recordedPerceptionRetryGeneration?: number;
|
||||||
|
lockPerceptionCameraInteraction?: boolean;
|
||||||
|
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordedRrdArtifactDescriptor {
|
export interface RecordedRrdArtifactDescriptor {
|
||||||
|
|
@ -566,10 +582,12 @@ export async function fetchRecordedPerceptionRrd(
|
||||||
origin,
|
origin,
|
||||||
signal,
|
signal,
|
||||||
fetcher = globalThis.fetch,
|
fetcher = globalThis.fetch,
|
||||||
|
onProgress,
|
||||||
}: {
|
}: {
|
||||||
origin: string;
|
origin: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
fetcher?: typeof globalThis.fetch;
|
fetcher?: typeof globalThis.fetch;
|
||||||
|
onProgress?: (receivedBytes: number, totalBytes: number) => void;
|
||||||
},
|
},
|
||||||
): Promise<Uint8Array | null> {
|
): Promise<Uint8Array | null> {
|
||||||
const base = new URL(origin);
|
const base = new URL(origin);
|
||||||
|
|
@ -609,9 +627,32 @@ export async function fetchRecordedPerceptionRrd(
|
||||||
) {
|
) {
|
||||||
throw new Error("Invalid recorded perception response");
|
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 (
|
if (
|
||||||
payload.byteLength !== declaredLength ||
|
receivedBytes !== declaredLength ||
|
||||||
payload[0] !== 0x52 ||
|
payload[0] !== 0x52 ||
|
||||||
payload[1] !== 0x52 ||
|
payload[1] !== 0x52 ||
|
||||||
payload[2] !== 0x46 ||
|
payload[2] !== 0x46 ||
|
||||||
|
|
@ -644,7 +685,9 @@ export function RerunViewport({
|
||||||
segmentation: false,
|
segmentation: false,
|
||||||
cuboids3d: false,
|
cuboids3d: false,
|
||||||
},
|
},
|
||||||
onPerceptionAvailabilityChange,
|
recordedPerceptionRetryGeneration = 0,
|
||||||
|
lockPerceptionCameraInteraction = false,
|
||||||
|
onPerceptionLoadChange,
|
||||||
}: RerunViewportProps) {
|
}: RerunViewportProps) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||||
|
|
@ -1189,7 +1232,7 @@ export function RerunViewport({
|
||||||
}, [recordedPerceptionUrl]);
|
}, [recordedPerceptionUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) return;
|
if (!recordedPerceptionUrl) return;
|
||||||
const active = perceptionChannelRef.current;
|
const active = perceptionChannelRef.current;
|
||||||
const identity = recordedIdentityRef.current;
|
const identity = recordedIdentityRef.current;
|
||||||
if (
|
if (
|
||||||
|
|
@ -1199,16 +1242,51 @@ export function RerunViewport({
|
||||||
!active.channel.ready
|
!active.channel.ready
|
||||||
) return;
|
) return;
|
||||||
if (loadedPerceptionChannelRef.current === active) {
|
if (loadedPerceptionChannelRef.current === active) {
|
||||||
onPerceptionAvailabilityChange?.(true);
|
onPerceptionLoadChange?.({
|
||||||
|
phase: "ready",
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progress: 1,
|
||||||
|
message: "AI-слои готовы.",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
let lastReportedPercent = -1;
|
||||||
|
onPerceptionLoadChange?.({
|
||||||
|
phase: "loading",
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progress: null,
|
||||||
|
message: "Сервер готовит AI-слои.",
|
||||||
|
});
|
||||||
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
signal: abort.signal,
|
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) => {
|
}).then((payload) => {
|
||||||
if (payload === null) {
|
if (payload === null) {
|
||||||
onPerceptionAvailabilityChange?.(false);
|
onPerceptionLoadChange?.({
|
||||||
|
phase: "unavailable",
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progress: null,
|
||||||
|
message: "Для этой сессии AI-слои не опубликованы.",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -1221,16 +1299,31 @@ export function RerunViewport({
|
||||||
}
|
}
|
||||||
active.channel.send_rrd(payload);
|
active.channel.send_rrd(payload);
|
||||||
loadedPerceptionChannelRef.current = active;
|
loadedPerceptionChannelRef.current = active;
|
||||||
onPerceptionAvailabilityChange?.(true);
|
onPerceptionLoadChange?.({
|
||||||
}).catch(() => {
|
phase: "ready",
|
||||||
onPerceptionAvailabilityChange?.(false);
|
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.
|
// The base recording remains available when no admitted perception layer exists.
|
||||||
});
|
});
|
||||||
return () => abort.abort();
|
return () => abort.abort();
|
||||||
}, [
|
}, [
|
||||||
onPerceptionAvailabilityChange,
|
onPerceptionLoadChange,
|
||||||
perceptionChannelRevision,
|
perceptionChannelRevision,
|
||||||
recordedPerceptionLayers.enabled,
|
recordedPerceptionRetryGeneration,
|
||||||
recordedPerceptionUrl,
|
recordedPerceptionUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1292,6 +1385,13 @@ export function RerunViewport({
|
||||||
className="rerun-viewport__canvas"
|
className="rerun-viewport__canvas"
|
||||||
data-presented={recordedArtifact === null || presentationStatus === "ready" ? "true" : "false"}
|
data-presented={recordedArtifact === null || presentationStatus === "ready" ? "true" : "false"}
|
||||||
/>
|
/>
|
||||||
|
{lockPerceptionCameraInteraction && presentationStatus === "ready" ? (
|
||||||
|
<div
|
||||||
|
className="rerun-viewport__camera-lock"
|
||||||
|
aria-hidden="true"
|
||||||
|
title="Операторское видео зафиксировано"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{presentationStatus === "loading" ? (
|
{presentationStatus === "loading" ? (
|
||||||
<div className="rerun-viewport__notice" role="status">
|
<div className="rerun-viewport__notice" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,39 @@
|
||||||
border-radius: 999px;
|
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 {
|
.spatial-viewport-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -95,6 +128,17 @@
|
||||||
height: 100% !important;
|
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:is([data-status="loading"], [data-status="error"])
|
||||||
.rerun-viewport__canvas {
|
.rerun-viewport__canvas {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
type RerunPlaybackState,
|
type RerunPlaybackState,
|
||||||
type RerunSelection,
|
type RerunSelection,
|
||||||
type RerunViewportStatus,
|
type RerunViewportStatus,
|
||||||
|
type RecordedPerceptionLoadState,
|
||||||
} from "../components/RerunViewport";
|
} from "../components/RerunViewport";
|
||||||
import {
|
import {
|
||||||
capabilityStatusLabel,
|
capabilityStatusLabel,
|
||||||
|
|
@ -307,9 +308,14 @@ function SpatialWorkspace({
|
||||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||||
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
||||||
const [perceptionAvailability, setPerceptionAvailability] = useState<
|
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
|
||||||
"unknown" | "available" | "unavailable"
|
phase: "idle",
|
||||||
>("unknown");
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progress: null,
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
|
||||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||||
|
|
@ -335,7 +341,9 @@ function SpatialWorkspace({
|
||||||
const mediaSources = observationSources.filter(
|
const mediaSources = observationSources.filter(
|
||||||
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
(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 =
|
const recordedPerceptionEnabled =
|
||||||
showDetections2d || showSegmentation || showCuboids3d;
|
showDetections2d || showSegmentation || showCuboids3d;
|
||||||
// The native recorded camera remains the authoritative original. Only 2D
|
// The native recorded camera remains the authoritative original. Only 2D
|
||||||
|
|
@ -421,9 +429,9 @@ function SpatialWorkspace({
|
||||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const onPerceptionAvailabilityChange = useCallback((available: boolean) => {
|
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
|
||||||
setPerceptionAvailability(available ? "available" : "unavailable");
|
setPerceptionLoad(next);
|
||||||
if (!available) {
|
if (next.phase === "unavailable" || next.phase === "error") {
|
||||||
setShowDetections2d(false);
|
setShowDetections2d(false);
|
||||||
setShowSegmentation(false);
|
setShowSegmentation(false);
|
||||||
setShowCuboids3d(false);
|
setShowCuboids3d(false);
|
||||||
|
|
@ -431,12 +439,19 @@ function SpatialWorkspace({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPerceptionAvailability("unknown");
|
setPerceptionLoad({
|
||||||
|
phase: recordedSource ? "loading" : "idle",
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progress: null,
|
||||||
|
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
|
||||||
|
});
|
||||||
|
setPerceptionRetryGeneration(0);
|
||||||
setShowDetections2d(false);
|
setShowDetections2d(false);
|
||||||
setShowSegmentation(false);
|
setShowSegmentation(false);
|
||||||
setShowCuboids3d(false);
|
setShowCuboids3d(false);
|
||||||
setRecordedViewResetGeneration(0);
|
setRecordedViewResetGeneration(0);
|
||||||
}, [sourceUrl]);
|
}, [recordedSource, sourceUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||||
|
|
@ -445,7 +460,13 @@ function SpatialWorkspace({
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
setPlaybackState(null);
|
setPlaybackState(null);
|
||||||
setPlaybackController(null);
|
setPlaybackController(null);
|
||||||
setPerceptionAvailability("unknown");
|
setPerceptionLoad({
|
||||||
|
phase: "idle",
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progress: null,
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
setShowDetections2d(false);
|
setShowDetections2d(false);
|
||||||
setShowSegmentation(false);
|
setShowSegmentation(false);
|
||||||
setShowCuboids3d(false);
|
setShowCuboids3d(false);
|
||||||
|
|
@ -507,6 +528,7 @@ function SpatialWorkspace({
|
||||||
variant={detections2dActive ? "primary" : "secondary"}
|
variant={detections2dActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="target" />}
|
icon={<Icon name="target" />}
|
||||||
aria-pressed={detections2dActive}
|
aria-pressed={detections2dActive}
|
||||||
|
disabled={recordedSource && !recordedPerceptionReady}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowDetections2d((current) => !current)
|
? setShowDetections2d((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
|
|
@ -521,6 +543,7 @@ function SpatialWorkspace({
|
||||||
variant={segmentationActive ? "primary" : "secondary"}
|
variant={segmentationActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="image" />}
|
icon={<Icon name="image" />}
|
||||||
aria-pressed={segmentationActive}
|
aria-pressed={segmentationActive}
|
||||||
|
disabled={recordedSource && !recordedPerceptionReady}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowSegmentation((current) => !current)
|
? setShowSegmentation((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
|
|
@ -535,6 +558,7 @@ function SpatialWorkspace({
|
||||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="apps" />}
|
icon={<Icon name="apps" />}
|
||||||
aria-pressed={cuboids3dActive}
|
aria-pressed={cuboids3dActive}
|
||||||
|
disabled={recordedSource && !recordedPerceptionReady}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowCuboids3d((current) => !current)
|
? setShowCuboids3d((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
|
|
@ -546,6 +570,22 @@ function SpatialWorkspace({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" ? (
|
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
|
|
@ -591,12 +631,17 @@ function SpatialWorkspace({
|
||||||
sceneSettings={sceneSettings}
|
sceneSettings={sceneSettings}
|
||||||
recordedViewResetGeneration={recordedViewResetGeneration}
|
recordedViewResetGeneration={recordedViewResetGeneration}
|
||||||
recordedPerceptionLayers={{
|
recordedPerceptionLayers={{
|
||||||
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
enabled:
|
||||||
|
recordedPerceptionSupported &&
|
||||||
|
recordedPerceptionReady &&
|
||||||
|
recordedPerceptionEnabled,
|
||||||
detections2d: showDetections2d,
|
detections2d: showDetections2d,
|
||||||
segmentation: showSegmentation,
|
segmentation: showSegmentation,
|
||||||
cuboids3d: showCuboids3d,
|
cuboids3d: showCuboids3d,
|
||||||
}}
|
}}
|
||||||
onPerceptionAvailabilityChange={onPerceptionAvailabilityChange}
|
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
|
||||||
|
lockPerceptionCameraInteraction={unifiedPerception}
|
||||||
|
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
onPlaybackChange={onPlaybackChange}
|
onPlaybackChange={onPlaybackChange}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/perception.rrd",
|
||||||
);
|
);
|
||||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||||
|
const progress = [];
|
||||||
const result = await fetchRecordedPerceptionRrd(
|
const result = await fetchRecordedPerceptionRrd(
|
||||||
endpoint,
|
endpoint,
|
||||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||||
{
|
{
|
||||||
origin: "http://127.0.0.1:5174",
|
origin: "http://127.0.0.1:5174",
|
||||||
|
onProgress: (receivedBytes, totalBytes) => {
|
||||||
|
progress.push([receivedBytes, totalBytes]);
|
||||||
|
},
|
||||||
fetcher: async (_input, init) => {
|
fetcher: async (_input, init) => {
|
||||||
assert.deepEqual(JSON.parse(String(init.body)), {
|
assert.deepEqual(JSON.parse(String(init.body)), {
|
||||||
application_id: "nodedc_mission_core_recorded",
|
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([...result], [...payload]);
|
||||||
|
assert.deepEqual(progress[0], [0, payload.byteLength]);
|
||||||
|
assert.deepEqual(progress.at(-1), [payload.byteLength, payload.byteLength]);
|
||||||
|
|
||||||
const absent = await fetchRecordedPerceptionRrd(
|
const absent = await fetchRecordedPerceptionRrd(
|
||||||
endpoint,
|
endpoint,
|
||||||
|
|
|
||||||
|
|
@ -105,16 +105,10 @@ class _CuboidPresentationState:
|
||||||
colors: np.ndarray,
|
colors: np.ndarray,
|
||||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, list[str]] | None:
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, list[str]] | None:
|
||||||
accepted = [
|
accepted = [
|
||||||
item
|
item for item in objects if str(item.get("cuboid_status", "")).startswith("accepted-")
|
||||||
for item in objects
|
|
||||||
if str(item.get("cuboid_status", "")).startswith("accepted-")
|
|
||||||
]
|
]
|
||||||
if not (
|
if not (
|
||||||
len(accepted)
|
len(accepted) == len(centers) == len(half_sizes) == len(quaternions) == len(colors)
|
||||||
== len(centers)
|
|
||||||
== len(half_sizes)
|
|
||||||
== len(quaternions)
|
|
||||||
== len(colors)
|
|
||||||
):
|
):
|
||||||
raise RecordedPerceptionOverlayError(
|
raise RecordedPerceptionOverlayError(
|
||||||
"integrated perception cuboid presentation arrays are inconsistent"
|
"integrated perception cuboid presentation arrays are inconsistent"
|
||||||
|
|
@ -343,6 +337,8 @@ class IntegratedPerceptionOverlayStore:
|
||||||
self.cache_root = cache_root.expanduser().absolute()
|
self.cache_root = cache_root.expanduser().absolute()
|
||||||
self.ffmpeg_path = ffmpeg_path.expanduser().absolute()
|
self.ffmpeg_path = ffmpeg_path.expanduser().absolute()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._catalog_signature: tuple[Any, ...] | None = None
|
||||||
|
self._latest_by_session: dict[str, IntegratedPerceptionResult | None] = {}
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
self,
|
self,
|
||||||
|
|
@ -402,10 +398,21 @@ class IntegratedPerceptionOverlayStore:
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def _latest(self, session_id: str) -> IntegratedPerceptionResult | None:
|
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:
|
try:
|
||||||
jobs = sorted(self.jobs_root.iterdir())
|
jobs = sorted(self.jobs_root.iterdir())
|
||||||
candidates = sorted(self.results_root.iterdir())
|
candidates = sorted(self.results_root.iterdir())
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
self._latest_by_session[session_id] = None
|
||||||
return None
|
return None
|
||||||
if len(jobs) > MAX_SCAN or len(candidates) > MAX_SCAN:
|
if len(jobs) > MAX_SCAN or len(candidates) > MAX_SCAN:
|
||||||
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
|
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
|
||||||
|
|
@ -436,8 +443,57 @@ class IntegratedPerceptionOverlayStore:
|
||||||
):
|
):
|
||||||
matches.append(value)
|
matches.append(value)
|
||||||
if not matches:
|
if not matches:
|
||||||
|
self._latest_by_session[session_id] = None
|
||||||
return 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(
|
def _render(
|
||||||
|
|
@ -630,9 +686,7 @@ def _camera_proxy_file(
|
||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
path = Path(name)
|
path = Path(name)
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
frame_filter = (
|
frame_filter = f"select=between(n\\,{start_frame_index}\\,{end_frame_index}),setpts=N/(10*TB)"
|
||||||
f"select=between(n\\,{start_frame_index}\\,{end_frame_index}),setpts=N/(10*TB)"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[
|
[
|
||||||
|
|
@ -900,9 +954,7 @@ def _canonical_json(value: object) -> bytes:
|
||||||
|
|
||||||
def _finite(value: object) -> TypeGuard[int | float]:
|
def _finite(value: object) -> TypeGuard[int | float]:
|
||||||
return (
|
return (
|
||||||
isinstance(value, int | float)
|
isinstance(value, int | float) and not isinstance(value, bool) and bool(np.isfinite(value))
|
||||||
and not isinstance(value, bool)
|
|
||||||
and bool(np.isfinite(value))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,16 @@ from __future__ import annotations
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import numpy as np
|
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]:
|
def _worker_modules() -> tuple[object, object]:
|
||||||
|
|
@ -35,6 +41,60 @@ def _worker_modules() -> tuple[object, object]:
|
||||||
sys.path.pop(0)
|
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:
|
def test_e10_profile_pins_integrated_realtime_budget() -> None:
|
||||||
_fusion, runner = _worker_modules()
|
_fusion, runner = _worker_modules()
|
||||||
profile_path = (
|
profile_path = (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue