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 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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in New Issue