refactor(lab): перевести RAV004 на канонический Rerun pipeline
This commit is contained in:
Generated
+4
-5
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
@@ -15,7 +14,7 @@
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"meshoptimizer": "1.1.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
@@ -900,9 +899,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@rerun-io/web-viewer": {
|
||||
"version": "0.34.1",
|
||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
|
||||
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
|
||||
"version": "0.36.3",
|
||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
||||
"integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
@@ -18,7 +17,7 @@
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"meshoptimizer": "1.1.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -151,6 +151,7 @@ interface RecordedRerunIdentity {
|
||||
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
||||
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
|
||||
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
|
||||
const LAB_RECORDED_PERCEPTION_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-overlay\.rrd$/;
|
||||
const RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.rrd$/;
|
||||
const MAX_BLUEPRINT_BYTES = 1_048_576;
|
||||
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
|
||||
@@ -203,12 +204,7 @@ export function resolveRerunSourceUrl(sourceUrl: string, origin: string): string
|
||||
return new URL(normalized, `${base.origin}/`).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* The native Rerun HTTP receiver is the only browser API in 0.34.1 with a
|
||||
* persistent incremental RRD decoder. Bind it to the exact immutable
|
||||
* generation selected by the launch contract; arbitrary `send_rrd` byte
|
||||
* slices are independently decoded files and are therefore invalid.
|
||||
*/
|
||||
/** Bind the upstream Rerun HTTP receiver to one immutable source generation. */
|
||||
export function resolveRecordedViewerSourceUrl(
|
||||
descriptor: RecordedRrdArtifactDescriptor,
|
||||
origin: string,
|
||||
@@ -238,12 +234,6 @@ export function rerunViewerInitialSource(
|
||||
return resolvedSourceUrl;
|
||||
}
|
||||
|
||||
export function rerunViewerOpenOptions(
|
||||
followLive: boolean,
|
||||
): { follow_if_http: true } | null {
|
||||
return followLive ? { follow_if_http: true } : null;
|
||||
}
|
||||
|
||||
export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string): string | null {
|
||||
const normalized = sourceUrl.trim();
|
||||
if (!RECORDED_RRD_PATH.test(normalized)) return null;
|
||||
@@ -365,6 +355,9 @@ export async function fetchRecordedBlueprintRrd(
|
||||
activeView = "spatial",
|
||||
viewResetGeneration = 0,
|
||||
followTrajectory = false,
|
||||
semanticLayer,
|
||||
unifiedPerception,
|
||||
planView = false,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
@@ -379,10 +372,15 @@ export async function fetchRecordedBlueprintRrd(
|
||||
activeView?: RecordedRerunView;
|
||||
viewResetGeneration?: 0 | 1;
|
||||
followTrajectory?: boolean;
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
unifiedPerception?: boolean;
|
||||
planView?: boolean;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
const resolvedUnifiedPerception =
|
||||
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
@@ -401,6 +399,9 @@ export async function fetchRecordedBlueprintRrd(
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
!["spatial", "perception", "perception3d", "metrics"].includes(activeView) ||
|
||||
![0, 1].includes(viewResetGeneration) ||
|
||||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
|
||||
typeof resolvedUnifiedPerception !== "boolean" ||
|
||||
typeof planView !== "boolean" ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
perceptionLayers.detections2d,
|
||||
@@ -435,8 +436,9 @@ export async function fetchRecordedBlueprintRrd(
|
||||
active_view: activeView,
|
||||
view_reset_generation: viewResetGeneration,
|
||||
follow_trajectory: followTrajectory,
|
||||
unified_perception:
|
||||
perceptionLayers.detections2d || perceptionLayers.segmentation,
|
||||
unified_perception: resolvedUnifiedPerception,
|
||||
semantic_layer: semanticLayer ?? null,
|
||||
plan_view: planView,
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_segmentation: perceptionLayers.segmentation,
|
||||
show_cuboids_3d: perceptionLayers.cuboids3d,
|
||||
@@ -487,7 +489,8 @@ export async function fetchRecordedPerceptionRrd(
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
!RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||
!(RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||
LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname)) ||
|
||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
||||
) {
|
||||
@@ -588,6 +591,10 @@ export function RerunViewport({
|
||||
segmentation: false,
|
||||
cuboids3d: false,
|
||||
};
|
||||
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
|
||||
const recordedSemanticLayer = recordedProfile?.semanticLayer;
|
||||
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
|
||||
const recordedPlanView = recordedProfile?.planView ?? false;
|
||||
const recordedPerceptionRetryGeneration =
|
||||
recordedProfile?.perceptionRetryGeneration ?? 0;
|
||||
const lockPerceptionCameraInteraction =
|
||||
@@ -621,9 +628,11 @@ export function RerunViewport({
|
||||
const recordedBlueprintUrl = sourceUrl
|
||||
? resolveRecordedBlueprintUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPerceptionUrl = sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPerceptionUrl = recordedPerceptionSourceUrl
|
||||
? resolveRerunSourceUrl(recordedPerceptionSourceUrl, window.location.origin)
|
||||
: sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPointColorsUrl = sourceUrl
|
||||
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
@@ -1264,7 +1273,7 @@ export function RerunViewport({
|
||||
try {
|
||||
const recordingId = viewer.get_active_recording_id();
|
||||
if (!recordingId) return;
|
||||
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
|
||||
// A live SDK receiver may ingest a store without forwarding its
|
||||
// recording_open event to the JavaScript wrapper. The active store
|
||||
// is the authoritative fallback and avoids hiding a ready canvas.
|
||||
admitRecording({
|
||||
@@ -1311,11 +1320,8 @@ export function RerunViewport({
|
||||
);
|
||||
|
||||
try {
|
||||
// `panel_state_overrides` is part of Rerun's runtime AppOptions but
|
||||
// omitted from the public WebViewerOptions declaration in 0.34.1.
|
||||
// Its generated TypeScript declaration says `hidden`, while the
|
||||
// WASM constructor actually deserializes the Rust enum `Hidden`.
|
||||
// Keep the post-start overrides below as a compatibility fallback.
|
||||
// Keep all application chrome in the Mission Core shell. Rerun owns
|
||||
// the synchronized canvas, data store and clock, not another window.
|
||||
const viewerOptions = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -1336,7 +1342,6 @@ export function RerunViewport({
|
||||
rerunViewerInitialSource(resolvedSource),
|
||||
host,
|
||||
viewerOptions,
|
||||
rerunViewerOpenOptions(followLive),
|
||||
);
|
||||
if (disposed) {
|
||||
disposeViewer();
|
||||
@@ -1429,6 +1434,7 @@ export function RerunViewport({
|
||||
recordedArtifact?.sha256,
|
||||
recordedArtifact?.sourceUrl,
|
||||
recordedArtifact?.viewerSourceUrl,
|
||||
recordedPerceptionUrl,
|
||||
retryNonce,
|
||||
]);
|
||||
|
||||
@@ -1484,6 +1490,7 @@ export function RerunViewport({
|
||||
message: "Сервер готовит AI-слои.",
|
||||
});
|
||||
const pollPreparationStatus = () => {
|
||||
if (!RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) return;
|
||||
void fetchPerceptionPreparationStatus(
|
||||
recordedPerceptionUrl,
|
||||
identity.recordingId,
|
||||
@@ -1704,6 +1711,9 @@ export function RerunViewport({
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: recordedFollowTrajectory,
|
||||
perceptionLayers: recordedPerceptionLayers,
|
||||
semanticLayer: recordedSemanticLayer,
|
||||
unifiedPerception: recordedUnifiedPerception,
|
||||
planView: recordedPlanView,
|
||||
}).then((payload) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
@@ -1725,6 +1735,9 @@ export function RerunViewport({
|
||||
recordedView,
|
||||
recordedViewResetGeneration,
|
||||
recordedFollowTrajectory,
|
||||
recordedSemanticLayer,
|
||||
recordedUnifiedPerception,
|
||||
recordedPlanView,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionLayers.detections2d,
|
||||
recordedPerceptionLayers.segmentation,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
|
||||
export interface CanonicalRecordedLabMode<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +91,7 @@ export function CanonicalRecordedLabReplay<
|
||||
mediaMultiLayer = false,
|
||||
mediaContent,
|
||||
spatialContent,
|
||||
unifiedContent,
|
||||
emptyMessage,
|
||||
deckOverlays,
|
||||
actions,
|
||||
@@ -115,8 +117,10 @@ export function CanonicalRecordedLabReplay<
|
||||
spatialLayerControls?: ReactNode;
|
||||
spatialLeadingControl?: ReactNode;
|
||||
mediaMultiLayer?: boolean;
|
||||
mediaContent: ReactNode;
|
||||
spatialContent: ReactNode;
|
||||
mediaContent?: ReactNode;
|
||||
spatialContent?: ReactNode;
|
||||
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
|
||||
unifiedContent?: ReactNode;
|
||||
emptyMessage: string;
|
||||
deckOverlays?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
@@ -220,7 +224,13 @@ export function CanonicalRecordedLabReplay<
|
||||
className="m4-replay-threat-visual__deck"
|
||||
data-split={splitView ? "true" : undefined}
|
||||
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
|
||||
data-native-rerun={unifiedContent ? "true" : undefined}
|
||||
>
|
||||
{unifiedContent ? (
|
||||
<div className="m4-replay-threat-visual__unified-content">
|
||||
{unifiedContent}
|
||||
</div>
|
||||
) : null}
|
||||
<SplitPane
|
||||
primary={mediaPane}
|
||||
secondary={spatialPane ?? <div />}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function laboratoryRecordedEvidenceDemand(
|
||||
profile: LaboratoryRecordedEvidenceViewerProfile =
|
||||
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||
): LaboratoryRecordedEvidenceDemand {
|
||||
if (profile.loadPolicy !== "visible-evidence-only") {
|
||||
if (profile.loadPolicy !== "explicit-legacy-comparison-only") {
|
||||
throw new Error("Unsupported LAB recorded evidence load policy");
|
||||
}
|
||||
const mediaVisible = visibility.mediaMode !== null;
|
||||
|
||||
@@ -67,20 +67,29 @@ export interface RecordedSessionRerunProfile {
|
||||
viewResetGeneration: 0 | 1;
|
||||
followTrajectory: boolean;
|
||||
perceptionLayers: RecordedPerceptionLayers;
|
||||
/** Optional immutable RRD sidecar for LAB/model evidence on the same recording clock. */
|
||||
perceptionSourceUrl?: string;
|
||||
/** Selects one semantic entity without changing the sealed sidecar. */
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||
unifiedPerception?: boolean;
|
||||
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||
planView?: boolean;
|
||||
perceptionRetryGeneration: number;
|
||||
lockPerceptionCameraInteraction: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* LAB recorded evidence is not a native Rerun mode. It owns a source-sequence
|
||||
* clock and composes the existing sealed fMP4 and retained spatial primitives.
|
||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||
* canonical LAB route or start work in the background.
|
||||
*/
|
||||
export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||
kind: "lab-recorded-evidence";
|
||||
clock: "source-sequence";
|
||||
cameraTransport: "generation-bound-fmp4";
|
||||
spatialTransport: "bounded-sealed-artifacts";
|
||||
loadPolicy: "visible-evidence-only";
|
||||
loadPolicy: "explicit-legacy-comparison-only";
|
||||
workerRequired: false;
|
||||
}
|
||||
|
||||
@@ -97,7 +106,7 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||
clock: "source-sequence",
|
||||
cameraTransport: "generation-bound-fmp4",
|
||||
spatialTransport: "bounded-sealed-artifacts",
|
||||
loadPolicy: "visible-evidence-only",
|
||||
loadPolicy: "explicit-legacy-comparison-only",
|
||||
workerRequired: false,
|
||||
} satisfies LaboratoryRecordedEvidenceViewerProfile);
|
||||
|
||||
|
||||
@@ -27,6 +27,41 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__unified-content {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__unified-content > *,
|
||||
.m4-replay-threat-visual__unified-content .rerun-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"] > .nodedc-split-pane {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.nodedc-split-pane__separator,
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane-toolbar,
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane-toolbar * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
|
||||
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Icon, SegmentedControl } from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import {
|
||||
RerunViewport,
|
||||
type RecordedPerceptionLoadState,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
} from "../../components/RerunViewport";
|
||||
import {
|
||||
CanonicalRecordedLabReplay,
|
||||
useCanonicalRecordedLabReplayState,
|
||||
} from "../../components/laboratory/CanonicalRecordedLabReplay";
|
||||
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import { defaultSceneSettings } from "../../sceneSettings";
|
||||
|
||||
type MediaMode = "video" | "camera";
|
||||
type SpatialMode = "3d" | "plan";
|
||||
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
||||
type SemanticLayer = "city" | "vegetation";
|
||||
|
||||
const EMPTY_PERCEPTION_LOAD: RecordedPerceptionLoadState = {
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
};
|
||||
|
||||
export function CanonicalVegetationRerunReplay({
|
||||
resultId,
|
||||
review,
|
||||
}: {
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
const {
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
expanded,
|
||||
onMediaModeChange,
|
||||
onSpatialModeChange,
|
||||
onSplitPrimarySizeChange,
|
||||
onExpandedChange,
|
||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [perceptionLoad, setPerceptionLoad] =
|
||||
useState<RecordedPerceptionLoadState>(EMPTY_PERCEPTION_LOAD);
|
||||
const [launch, setLaunch] = useState<Awaited<ReturnType<typeof resolveObservationSessionReplay>> | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLaunch(null);
|
||||
setLaunchError(null);
|
||||
void resolveObservationSessionReplay(review.sessionId, {
|
||||
signal: controller.signal,
|
||||
maximumWaitMs: 30 * 60 * 1000,
|
||||
onUpdate: () => undefined,
|
||||
}).then((value) => {
|
||||
if (!controller.signal.aborted) setLaunch(value);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLaunchError(
|
||||
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [review.sessionId]);
|
||||
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const sceneSettings = useMemo(() => ({
|
||||
...defaultSceneSettings,
|
||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||
showPoints: spatialMode !== null,
|
||||
showTrajectory: spatialMode !== null,
|
||||
showGrid: spatialMode !== null,
|
||||
pointSize: 2.2,
|
||||
}), [spatialLayer, spatialMode]);
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
sourceUrl: launch.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.sourceUrl,
|
||||
viewerSourceUrl: launch.viewerSourceUrl,
|
||||
byteLength: launch.byteLength,
|
||||
sha256: launch.sha256,
|
||||
},
|
||||
autoplayWhenReady: false,
|
||||
presentationGate: "ready",
|
||||
expectedTimelineStartSeconds: launch.timelineStartSeconds,
|
||||
expectedTimelineEndSeconds: launch.timelineEndSeconds,
|
||||
initialPlaybackStartSeconds: review.timelineStartSeconds,
|
||||
view: mediaMode !== null ? "perception" : "spatial",
|
||||
viewResetGeneration,
|
||||
followTrajectory: false,
|
||||
perceptionSourceUrl:
|
||||
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}` +
|
||||
"/canonical-overlay.rrd",
|
||||
semanticLayer,
|
||||
unifiedPerception: splitView,
|
||||
planView: spatialMode === "plan",
|
||||
perceptionLayers: {
|
||||
enabled: mediaMode !== null,
|
||||
detections2d: mediaMode === "video",
|
||||
segmentation: mediaMode === "video" && showSemantics,
|
||||
cuboids3d: false,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: false,
|
||||
}) : null;
|
||||
|
||||
const mediaLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="compact"
|
||||
shape="pill"
|
||||
variant={showSemantics ? "primary" : "secondary"}
|
||||
aria-pressed={showSemantics}
|
||||
onClick={() => setShowSemantics((visible) => !visible)}
|
||||
>
|
||||
SEMANTICS
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[
|
||||
{ value: "city", label: "ГОРОД · EoMT" },
|
||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||
]}
|
||||
label="Источник семантики"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowSemantics(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Пространственные слои RAV004"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={spatialLayer}
|
||||
items={[
|
||||
{ value: "source", label: "SOURCE POINTS" },
|
||||
{ value: "local", label: "LOCAL SLAM" },
|
||||
{ value: "tgs", label: "TGS COSTMAP", disabled: true },
|
||||
{ value: "semantic", label: "SEMANTICS", disabled: true },
|
||||
]}
|
||||
label="Пространственные слои"
|
||||
onChange={setSpatialLayer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
aria-label="Сбросить положение 3D камеры"
|
||||
title="Сбросить положение 3D камеры"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||
>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const transport = playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="shared-clock"
|
||||
rangeNs={playback.rangeNs}
|
||||
currentNs={playback.currentNs}
|
||||
playing={playback.playing}
|
||||
onSeek={playbackController.seek}
|
||||
onPlayingChange={playbackController.setPlaying}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
const overlayMessage = launchError
|
||||
?? (perceptionLoad.phase === "loading" ? perceptionLoad.message : null)
|
||||
?? (perceptionLoad.phase === "error" ? perceptionLoad.message : null);
|
||||
|
||||
return (
|
||||
<CanonicalRecordedLabReplay
|
||||
label="RAVNOVES004TREE · upstream Rerun recorded replay"
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer
|
||||
unifiedContent={profile ? (
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
onPerceptionLoadChange={setPerceptionLoad}
|
||||
/>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role={launchError ? "alert" : "status"}>
|
||||
{launchError ?? "Открываем каноническую запись RAV004…"}
|
||||
</div>
|
||||
)}
|
||||
emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий Rerun-clock останется на месте."
|
||||
deckOverlays={overlayMessage ? (
|
||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||
{perceptionLoad.phase === "loading" ? (
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="alert" size={14} />
|
||||
)}
|
||||
<span>{overlayMessage}</span>
|
||||
</div>
|
||||
) : undefined}
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import {
|
||||
vegetationFullRouteMaskUrl,
|
||||
vegetationVideoMaskUrl,
|
||||
type VegetationFullRouteReview,
|
||||
type VegetationShadowResult,
|
||||
@@ -17,13 +16,7 @@ import {
|
||||
type M49TgsFullShadowResult,
|
||||
} from "../../core/laboratory/m49TgsFullShadow";
|
||||
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
||||
import {
|
||||
M4ReplayThreatVisual,
|
||||
type M4ReplayClassifiedSpatialLayer,
|
||||
type M4ReplayThreatSemanticLayer,
|
||||
} from "./M4ReplayThreatVisual";
|
||||
|
||||
const VEGETATION_TIMELINE_ENDPOINT = "/api/v1/laboratory/vegetation-shadow";
|
||||
import { CanonicalVegetationRerunReplay } from "./CanonicalVegetationRerunReplay";
|
||||
|
||||
function decimal(value: number, digits = 1): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
@@ -36,56 +29,7 @@ function FullRouteReviewEvidence({
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
const semanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(() => ([
|
||||
{
|
||||
id: "city",
|
||||
controlLabel: "ГОРОД · EoMT",
|
||||
resultId,
|
||||
spatialResultId: null,
|
||||
taxonomy: review.city.taxonomy,
|
||||
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "city", sequence),
|
||||
label: review.city.name,
|
||||
maskAriaLabel: "EoMT city semantic prediction",
|
||||
},
|
||||
{
|
||||
id: "vegetation",
|
||||
controlLabel: "ПРИРОДА · DDRNet",
|
||||
resultId,
|
||||
spatialResultId: null,
|
||||
taxonomy: review.vegetation.taxonomy,
|
||||
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "vegetation", sequence),
|
||||
label: review.vegetation.name,
|
||||
maskAriaLabel: "DDRNet nature semantic prediction",
|
||||
},
|
||||
]), [resultId, review.city, review.vegetation]);
|
||||
const sealedSpatialGap = useMemo<M4ReplayClassifiedSpatialLayer>(() => ({
|
||||
label: "RAVNOVES004TREE",
|
||||
pointLayerLabel: "SOURCE POINTS",
|
||||
cellLayerLabel: "TGS COSTMAP",
|
||||
cellLayerAvailable: false,
|
||||
expectedAtSequence: false,
|
||||
frame: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
replacePointCloud: false,
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<M4ReplayThreatVisual
|
||||
resultId={resultId}
|
||||
timelineEndpointRoot={VEGETATION_TIMELINE_ENDPOINT}
|
||||
semanticLayers={semanticLayers}
|
||||
initialSemanticLayerId="vegetation"
|
||||
initialSpatialMode="3d"
|
||||
classifiedSpatialLayer={sealedSpatialGap}
|
||||
evidenceLabel="RAVNOVES004TREE"
|
||||
playbackTransport="segmented"
|
||||
spatialPlaybackTransport="sealed-binary"
|
||||
recoverTimestampStalls
|
||||
showReferenceMediaLayers
|
||||
showSpatialOverlaySummary
|
||||
/>
|
||||
);
|
||||
return <CanonicalVegetationRerunReplay resultId={resultId} review={review} />;
|
||||
}
|
||||
|
||||
function FullRouteReviewResult({
|
||||
|
||||
@@ -485,6 +485,8 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
view_reset_generation: 1,
|
||||
follow_trajectory: true,
|
||||
unified_perception: true,
|
||||
semantic_layer: null,
|
||||
plan_view: false,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
show_cuboids_3d: true,
|
||||
|
||||
@@ -13,7 +13,6 @@ let liveTimelineNeedsSynchronization;
|
||||
let liveRerunReceiverBindingIdentity;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let rerunViewerOpenOptions;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
|
||||
before(async () => {
|
||||
@@ -31,7 +30,6 @@ before(async () => {
|
||||
liveRerunReceiverBindingIdentity,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
rerunViewerOpenOptions,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
@@ -71,11 +69,6 @@ test("only the canonical digest-bound generation URL reaches the native Rerun re
|
||||
}
|
||||
});
|
||||
|
||||
test("only the live receiver opens on the native following edge", () => {
|
||||
assert.deepEqual(rerunViewerOpenOptions(true), { follow_if_http: true });
|
||||
assert.equal(rerunViewerOpenOptions(false), null);
|
||||
});
|
||||
|
||||
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
||||
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false);
|
||||
assert.equal(isLiveRerunPresentationReady(true, null, 1), false);
|
||||
@@ -216,7 +209,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||
assert.doesNotMatch(source, /recordedChannel/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(source, /rerunViewerOpenOptions\(followLive\)/);
|
||||
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
|
||||
|
||||
@@ -1,81 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
||||
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
const sha256 = (path) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
|
||||
const application = readJson(resolve(root, "package.json"));
|
||||
const installed = readJson(resolve(packageRoot, "package.json"));
|
||||
|
||||
test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
assert.equal(manifest.version, "0.34.1");
|
||||
|
||||
const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
|
||||
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
|
||||
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
|
||||
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
||||
assert.equal(installed.version, "0.36.3");
|
||||
assert.equal(application.scripts.postinstall, undefined);
|
||||
});
|
||||
|
||||
test("custom JavaScript glue references only exports present in its paired WASM", () => {
|
||||
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm");
|
||||
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js");
|
||||
const module = new WebAssembly.Module(readFileSync(wasmPath));
|
||||
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
|
||||
const imports = WebAssembly.Module.imports(module);
|
||||
const glue = readFileSync(gluePath, "utf8");
|
||||
const referencedExports = new Set(
|
||||
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
|
||||
);
|
||||
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
|
||||
test("the active application never imports or installs the archived vendor fork", () => {
|
||||
const application = readFileSync(resolve(root, "package.json"), "utf8");
|
||||
const viewport = readFileSync(resolve(root, "src/components/RerunViewport.tsx"), "utf8");
|
||||
|
||||
assert.equal(imports.length, 927);
|
||||
assert.equal(exports.size, 79);
|
||||
assert.deepEqual(missingExports, []);
|
||||
assert.match(glue, /export default function\(\)/);
|
||||
assert.match(glue, /if \(!wasm\) return;/);
|
||||
assert.doesNotMatch(application, /patch-rerun-web-viewer/);
|
||||
assert.doesNotMatch(application, /vendor\/rerun-web-viewer/);
|
||||
assert.doesNotMatch(viewport, /vendor\/rerun-web-viewer/);
|
||||
assert.doesNotMatch(viewport, /0\.34\.1/);
|
||||
});
|
||||
|
||||
test("custom Rerun WASM initializes and grows its externref table", () => {
|
||||
const runtime = makeRerunRuntime();
|
||||
runtime.initSync({
|
||||
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
|
||||
});
|
||||
test("upstream WebViewer exposes one three-argument start contract", () => {
|
||||
const declaration = readFileSync(resolve(packageRoot, "index.d.ts"), "utf8");
|
||||
|
||||
assert.equal(typeof runtime.WebHandle, "function");
|
||||
runtime.deinit();
|
||||
});
|
||||
|
||||
test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => {
|
||||
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
|
||||
|
||||
assert.match(patch, /fn pointer_ray_direction/);
|
||||
assert.match(patch, /fn zoom_orbit_towards_pointer/);
|
||||
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
|
||||
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
|
||||
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
|
||||
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
|
||||
assert.match(patch, /fn rotate_radians_around_anchor/);
|
||||
assert.match(patch, /orbit_drag_anchor/);
|
||||
assert.match(patch, /minimum_orbital_navigation_speed/);
|
||||
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
|
||||
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
|
||||
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
|
||||
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/);
|
||||
assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/);
|
||||
assert.match(
|
||||
patch,
|
||||
/persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/,
|
||||
declaration,
|
||||
/start\(rrd: string \| string\[\] \| null, parent: HTMLElement \| null, options: WebViewerOptions \| null\): Promise<void>/,
|
||||
);
|
||||
assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/);
|
||||
assert.match(patch, /previous_picking_result/);
|
||||
});
|
||||
|
||||
@@ -451,8 +451,8 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
|
||||
assert.deepEqual(frame.bodyFrame.originMapXyzM, [33, 4, 1]);
|
||||
});
|
||||
|
||||
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => {
|
||||
const [resultSource, benchmarkSource, m49Source, canonicalSource] = await Promise.all([
|
||||
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
|
||||
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -469,6 +469,10 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
||||
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/workspaces/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||
@@ -478,18 +482,20 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
|
||||
assert.match(resultSource, /CANONICAL RECORDED LAB · RAVNOVES004TREE/);
|
||||
assert.match(resultSource, /<M4ReplayThreatVisual/);
|
||||
assert.match(resultSource, /timelineEndpointRoot=\{VEGETATION_TIMELINE_ENDPOINT\}/);
|
||||
assert.match(resultSource, /playbackTransport="segmented"/);
|
||||
assert.match(resultSource, /recoverTimestampStalls/);
|
||||
assert.match(resultSource, /<CanonicalVegetationRerunReplay/);
|
||||
assert.doesNotMatch(resultSource, /RerunViewport/);
|
||||
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
|
||||
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
|
||||
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
|
||||
assert.match(resultSource, /SOURCE POINTS/);
|
||||
assert.match(resultSource, /TGS COSTMAP/);
|
||||
assert.match(rerunSource, /<RerunViewport/);
|
||||
assert.match(rerunSource, /SOURCE POINTS/);
|
||||
assert.match(rerunSource, /LOCAL SLAM/);
|
||||
assert.match(rerunSource, /TGS COSTMAP/);
|
||||
assert.match(rerunSource, /canonical-overlay\.rrd/);
|
||||
assert.match(rerunSource, /unifiedPerception: splitView/);
|
||||
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
||||
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||
assert.match(resultSource, /cellLayerAvailable: false/);
|
||||
assert.match(rerunSource, /value: "tgs", label: "TGS COSTMAP", disabled: true/);
|
||||
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||
|
||||
@@ -75,13 +75,13 @@ test("recorded session profile owns progressive admission and on-demand layers",
|
||||
assert.equal(profile.perceptionLayers.enabled, false);
|
||||
});
|
||||
|
||||
test("LAB recorded evidence remains outside native Rerun lifecycle", () => {
|
||||
test("the old LAB transport is fenced as explicit legacy comparison only", () => {
|
||||
assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, {
|
||||
kind: "lab-recorded-evidence",
|
||||
clock: "source-sequence",
|
||||
cameraTransport: "generation-bound-fmp4",
|
||||
spatialTransport: "bounded-sealed-artifacts",
|
||||
loadPolicy: "visible-evidence-only",
|
||||
loadPolicy: "explicit-legacy-comparison-only",
|
||||
workerRequired: false,
|
||||
});
|
||||
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Archived viewer comparison source
|
||||
|
||||
`rerun-web-viewer-0.34.1/` is retained only as rollback-era comparison evidence
|
||||
for ADR 0045. The active application neither imports it nor runs the historical
|
||||
patch scripts during install or build. Canonical Control Station routes use the
|
||||
unmodified `@rerun-io/web-viewer` dependency declared in `package.json`.
|
||||
|
||||
Do not update or reactivate this tree. Remove it with the remaining custom
|
||||
fMP4/Three.js replay implementation after the canonical Rerun migration passes
|
||||
operator visual acceptance.
|
||||
@@ -172,7 +172,7 @@ On the first live or adapter file-replay session, `RerunBridge`:
|
||||
|
||||
At every session start it resets the trajectory, current point count, metrics,
|
||||
blueprint and session-local visible time, then feeds the new source through the
|
||||
same recording. This process-wide lifecycle is intentional: the Rerun 0.34.1
|
||||
same recording. This process-wide lifecycle is intentional: the upstream Rerun 0.36.3
|
||||
browser receiver can remain connected after canvas teardown, so restarting the
|
||||
native listener on the same port is not a reliable session boundary.
|
||||
|
||||
|
||||
@@ -185,6 +185,15 @@ The evidence slot has two admitted renderers:
|
||||
- `diagnostic-model` — a specialized visual result such as the LAB E28 L2.6
|
||||
surface/timeline/review viewer.
|
||||
|
||||
ADR 0045 amends the full-session `recorded-replay` transport. A canonical
|
||||
recorded LAB uses one unmodified upstream Rerun viewer, recording identity and
|
||||
timeline for camera, semantic and spatial evidence. The source RRD owns pose,
|
||||
point cloud and trajectory; a digest-bound RRD sidecar may add only immutable
|
||||
derived entities which are absent from the source. Model or layer selection is
|
||||
a blueprint/profile change, not a new player or renderer. Missing full-route
|
||||
TGS, semantic 3D, boxes or cuboids remain disabled instead of being inferred
|
||||
from sparse review artifacts.
|
||||
|
||||
Recorded camera clips used for review are a frozen sub-contract of the admitted
|
||||
viewer, `missioncore.laboratory-recorded-clip-viewer/v1`, implemented by
|
||||
`LaboratoryRecordedClipPlayer`. It owns the generation-bound fMP4 manifest,
|
||||
@@ -197,6 +206,10 @@ Forward frame progression may roll an already-buffered segment target; a
|
||||
backward seek or clip loop must perform an explicit decoder seek and remain
|
||||
decoder-ready without exposing a per-frame loader.
|
||||
|
||||
That clip contract remains available to historical bounded clip instruments.
|
||||
It is legacy comparison transport for a migrated full-session LAB and must not
|
||||
be mounted, prefetched or run in parallel with the canonical Rerun profile.
|
||||
|
||||
When recorded camera and frame-indexed spatial evidence are both required for
|
||||
one review question, the shared player presents them simultaneously on the same
|
||||
media clock. The camera remains the clock owner; a bounded experiment-neutral
|
||||
|
||||
@@ -160,6 +160,15 @@ history of rejected approaches belong in Ops.
|
||||
|
||||
Primary visual evidence is hosted in one reusable viewer frame.
|
||||
|
||||
For canonical full-session recorded replay, ADR 0045 fixes one native upstream
|
||||
Rerun viewer beneath this frame. The accepted product chrome and switching
|
||||
logic remain Mission Core UI, while Rerun alone owns playback time, video
|
||||
decoding, 2D annotations, point-cloud rendering, trajectory and 3D camera.
|
||||
VIDEO/CAMERA, SOURCE POINTS/LOCAL SLAM/TGS COSTMAP/SEMANTICS and 3D/PLAN select
|
||||
entities, visible time ranges and blueprints in that mounted viewer. They never
|
||||
start independent transports or render loops. A LAB may rename model buttons or
|
||||
add an admitted layer button, but it may not change this switching architecture.
|
||||
|
||||
### Frozen Milestone 4 perception instruments
|
||||
|
||||
Milestone 4 admits exactly two operator instruments inside the shared LAB page
|
||||
@@ -234,6 +243,13 @@ The shared player may retain a rolling target only for the same or a later
|
||||
segment. Rewind and clip-loop transitions must seek backward explicitly while
|
||||
keeping the admitted generation and decoder owner mounted.
|
||||
|
||||
The preceding fMP4 rule applies to historical bounded clip instruments. A
|
||||
migrated full-session recorded LAB instead uses the native Rerun `AssetVideo`
|
||||
and `VideoFrameReference` contract from ADR 0045. It must not mount the clip
|
||||
player or a custom Three.js scene alongside Rerun. The visual controls and LAB
|
||||
template are identical in both cases; the canonical profile determines the one
|
||||
active transport.
|
||||
|
||||
### 3D and 2D policy
|
||||
|
||||
Choose the default representation from the operator question:
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# ADR 0045: Upstream Rerun as the canonical recorded-LAB pipeline
|
||||
|
||||
Date: 2026-08-30
|
||||
Status: accepted; RAVNOVES004TREE is the first migrated full-route LAB
|
||||
|
||||
## Context
|
||||
|
||||
The accepted LAB product composition was repeatedly rebuilt over independent
|
||||
camera, semantic, point-cloud and TGS transports. The resulting implementation
|
||||
had several clocks, LAB-specific caches, a browser MediaSource decoder and a
|
||||
separate Three.js spatial renderer. A camera could continue while segmentation
|
||||
or the cloud stopped; rewind could expose evidence from different source
|
||||
sequences; switching TGS could block both panes. Low host utilization did not
|
||||
make that architecture correct: the bottleneck was duplicated admission,
|
||||
decoding, scheduling and state ownership.
|
||||
|
||||
The product owner requires the existing LAB UI and interaction grammar to stay
|
||||
unchanged. VIDEO/CAMERA, SOURCE POINTS/LOCAL SLAM/TGS COSTMAP/SEMANTICS and
|
||||
3D/PLAN remain the canonical controls. Models and evidence providers may
|
||||
change, but a LAB may not create another player, clock, splitter, spatial
|
||||
renderer, window or status grammar.
|
||||
|
||||
The repository state before this migration is retained by the annotated Git
|
||||
tag `baseline/custom-legacy-before-canonical-rerun-2026-08-30`. Its Russian
|
||||
stage name is **«Этап перехода от самописного legacy-контура к каноническому
|
||||
шаблонному Rerun-пайплайну»**.
|
||||
|
||||
## Decision
|
||||
|
||||
Recorded LAB replay uses the unmodified upstream Rerun SDK and web viewer. The
|
||||
first accepted dependency is exactly `rerun-sdk==0.36.3` and
|
||||
`@rerun-io/web-viewer@0.36.3`. Mission Core does not patch the package, vendor a
|
||||
viewer fork or depend on private viewer source. Product controls are an outer
|
||||
adapter which requests an ordinary Rerun blueprint.
|
||||
|
||||
One native Rerun viewer owns:
|
||||
|
||||
- one `session_time` playback clock;
|
||||
- the recorded camera and semantic image-space evidence;
|
||||
- `/world/points`, `/world/sensor_pose` and `/world/trajectory`;
|
||||
- native 3D orbit and top-down plan presentation;
|
||||
- seek, play/pause and frame synchronization.
|
||||
|
||||
The canonical K1 RRD remains the source of pose, source points, bounded Local
|
||||
SLAM accumulation and trajectory. A LAB may publish one immutable normalized
|
||||
RRD sidecar containing only derived evidence absent from that recording, such
|
||||
as camera video, semantic masks and diagnostic 2D boxes. The base recording and
|
||||
sidecar must have the same application id, recording id and timeline. A sidecar
|
||||
does not copy, rotate or re-own world geometry.
|
||||
|
||||
RAVNOVES004TREE uses a digest-bound sidecar cache. Its sealed fMP4 fragments are
|
||||
verified, concatenated and transcoded once to an upstream-compatible H.264
|
||||
`AssetVideo`. Source PTS are preserved. A fragment without a decodable sample
|
||||
holds the latest preceding frame; decoded samples are never renumbered to a
|
||||
synthetic fixed-rate clock. Each semantic mask and `VideoFrameReference` is
|
||||
logged at the exact immutable LAB `session_time`.
|
||||
|
||||
Profiles control loading rather than creating different viewers:
|
||||
|
||||
- source points use zero accumulation;
|
||||
- Local SLAM uses a native five-second visible time range;
|
||||
- TGS COSTMAP and 3D SEMANTICS are enabled only when full-route immutable
|
||||
artifacts exist and share the recording clock;
|
||||
- semantic model buttons select an entity path in the same sidecar;
|
||||
- 3D/PLAN changes native eye controls, never point coordinates;
|
||||
- layers missing from an immutable result stay visibly disabled and fail
|
||||
closed; they are not reconstructed from sparse review anchors.
|
||||
|
||||
The previous fMP4/Three.js LAB transport remains source-retained only for
|
||||
explicit legacy comparison. No canonical route selects it, preloads it or lets
|
||||
it start background work. Removal is allowed after migrated results pass the
|
||||
same acceptance checks and the rollback tag is no longer operationally needed.
|
||||
|
||||
## Acceptance
|
||||
|
||||
A migrated recorded LAB is accepted only when:
|
||||
|
||||
1. the base RRD identity and sidecar identity match exactly;
|
||||
2. camera, semantics, point cloud, pose and trajectory follow one Rerun clock;
|
||||
3. play, pause, forward seek and backward seek do not remount the viewer;
|
||||
4. SOURCE POINTS and Local SLAM are native views of the same sealed geometry;
|
||||
5. unavailable TGS or semantic 3D evidence is disabled rather than simulated;
|
||||
6. first materialization is cached by source/result/renderer digests and a
|
||||
cache hit performs no decode or inference;
|
||||
7. the existing LAB page, selectors, report mode, controls and expand behavior
|
||||
remain unchanged;
|
||||
8. Data replay and live Rerun profiles continue to use their own load policies;
|
||||
9. the integrated application remains on `127.0.0.1:8000` and no second Mission
|
||||
Core service is introduced.
|
||||
|
||||
The isolated renderer materialized the first real RAVNOVES004TREE sidecar in
|
||||
79.5 seconds. Under the live operator service, cold materialization completed
|
||||
in approximately seven minutes and produced a 393,203,594-byte RRD; this is too
|
||||
slow to treat as an interactive open and should be moved to publication-time
|
||||
preparation. With a full SHA-256 recheck on every cache hit, the warm product
|
||||
endpoint returned headers in 0.89 seconds and streamed the complete local
|
||||
artifact in 2.48 seconds. These measurements establish the local cache behavior,
|
||||
not a realtime inference or navigation claim.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Mission Core keeps its product UI without owning media or spatial playback.
|
||||
- Rerun can be upgraded through ordinary dependency updates and regression
|
||||
tests instead of reapplying a local patch.
|
||||
- New models publish entities and annotations into the same recording contract;
|
||||
they do not add LAB-specific viewers.
|
||||
- SLAM clouds and trajectories stay visible through standard Rerun components.
|
||||
- Useful native boxes/cuboids may be added as ordinary entity layers when their
|
||||
immutable full-route evidence exists.
|
||||
- The migration does not improve DDRNet quality, prove terrain traversability
|
||||
or grant navigation/actuation authority. Those remain separate model and
|
||||
safety acceptance questions.
|
||||
@@ -6,7 +6,59 @@ Scope: Mission Core recorded LAB replay, RAVNOVES004TREE, OPS perception state
|
||||
|
||||
Excluded: Gaussian/simulation workers and their artifacts
|
||||
|
||||
## Outcome
|
||||
Status: the diagnostic findings and perception conclusions remain evidence, but
|
||||
the custom fMP4/Three.js implementation described below is superseded by
|
||||
ADR 0045. It is retained here as the failure audit, not as the current replay
|
||||
contract.
|
||||
|
||||
## Current outcome after ADR 0045
|
||||
|
||||
RAVNOVES004TREE now uses one unmodified upstream Rerun 0.36.3 viewer for camera,
|
||||
semantic masks, diagnostic boxes, source points, bounded Local SLAM, trajectory,
|
||||
3D/PLAN and playback. The canonical source RRD owns world geometry; a verified
|
||||
immutable RRD sidecar adds only LAB image-space evidence with the same recording
|
||||
id and `session_time`. Source PTS are preserved, so missing decodable video
|
||||
samples hold the previous frame instead of shortening the route or drifting
|
||||
from masks.
|
||||
|
||||
The accepted LAB controls and layout remain unchanged. Full-route TGS and
|
||||
point-aligned 3D semantics are still absent and therefore remain visible but
|
||||
disabled. The former fMP4/Three.js route is comparison-only legacy and does not
|
||||
load on the canonical RAV004 route.
|
||||
|
||||
The isolated renderer materialized the sidecar in 79.5 seconds. The canonical
|
||||
live service cold-path took approximately seven minutes and produced
|
||||
393,203,594 bytes, so publication-time preparation remains required before this
|
||||
profile is called immediately openable. With a complete SHA-256 check on every
|
||||
cache hit, the warm endpoint returned headers in 0.89 seconds and streamed the
|
||||
local artifact in 2.48 seconds. This is a replay/cache measurement, not a
|
||||
realtime inference claim.
|
||||
|
||||
## Current validation after ADR 0045
|
||||
|
||||
- frontend unit suite: 661 passed;
|
||||
- TypeScript typecheck and production Vite build: passed;
|
||||
- migration-scoped backend/API suite: 60 passed;
|
||||
- live canonical blueprint: HTTP 200, 86,343 bytes, 0.185 seconds;
|
||||
- warm integrity-checked sidecar: HTTP 200, byte-range `RRF2` confirmed;
|
||||
- canonical service restarted and healthy on `127.0.0.1:8000`; no Mission Core
|
||||
listener exists on `8765`.
|
||||
|
||||
The repository-wide Python suite completed with ten failures in pre-existing K1
|
||||
camera-recovery/scenario-reset tests. No K1 runtime or test file differs from
|
||||
the rollback tag in this migration. Nine failures are in the existing active
|
||||
acquisition camera-restart contract; one is an existing expected-document
|
||||
mismatch after the runtime added reset timing. These do not invalidate the
|
||||
Rerun-specific checks, but the repository-wide suite is not represented as
|
||||
green.
|
||||
|
||||
Automated in-app visual QA could not attach to the local address because the
|
||||
browser surface rejected the localhost URL under its URL policy. No alternate
|
||||
browser-control bypass was used. The live HTTP/data plane, build and contracts
|
||||
were accepted; an operator visual pass remains required for the exact layout,
|
||||
seek and toggle experience.
|
||||
|
||||
## Superseded implementation outcome
|
||||
|
||||
RAVNOVES004TREE no longer owns a custom LAB viewer. It supplies recording and
|
||||
model configuration to the same `M4ReplayThreatVisual` and
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"paho-mqtt>=2.1,<3",
|
||||
"pillow>=12,<13",
|
||||
"pyyaml>=6.0,<7",
|
||||
"rerun-sdk==0.34.1",
|
||||
"rerun-sdk==0.36.3",
|
||||
"rich>=13.9,<15",
|
||||
"typer>=0.15,<1",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
"""Native Rerun sidecar for immutable recorded laboratory evidence.
|
||||
|
||||
The sidecar deliberately contains only evidence missing from the canonical K1
|
||||
recording: camera video, semantic images and diagnostic 2D boxes. The base RRD
|
||||
continues to own poses, point clouds and trajectory. Both files use the same
|
||||
Rerun recording id and ``session_time`` timeline, so the upstream viewer is the
|
||||
only playback clock and the only spatial renderer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import zipfile
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
import rerun_bindings as bindings
|
||||
from PIL import Image
|
||||
|
||||
APPLICATION_ID: Final = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE: Final = "session_time"
|
||||
RENDERER_VERSION: Final = "upstream-rerun-0.36.3-v2"
|
||||
MAX_SOURCE_BYTES: Final = 768 * 1024 * 1024
|
||||
MAX_OVERLAY_BYTES: Final = 512 * 1024 * 1024
|
||||
|
||||
|
||||
class CanonicalLabOverlayError(RuntimeError):
|
||||
"""A sealed LAB result could not be projected into a native Rerun sidecar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalLabOverlayArtifact:
|
||||
path: Path
|
||||
byte_length: int
|
||||
sha256: str
|
||||
|
||||
|
||||
_render_lock = threading.Lock()
|
||||
_memory_cache: dict[tuple[str, str, str], CanonicalLabOverlayArtifact] = {}
|
||||
|
||||
|
||||
def canonical_recording_id(path: Path) -> str:
|
||||
"""Read the single data-store identity from a sealed base RRD."""
|
||||
|
||||
try:
|
||||
entries = bindings.RrdReaderInternal(str(path.resolve(strict=True))).store_entries()
|
||||
matches = [
|
||||
entry.recording_id
|
||||
for entry in entries
|
||||
if entry.kind == "recording" and entry.application_id == APPLICATION_ID
|
||||
]
|
||||
except Exception as exc:
|
||||
raise CanonicalLabOverlayError("canonical recording identity is unavailable") from exc
|
||||
if len(matches) != 1 or not matches[0] or len(matches[0]) > 128:
|
||||
raise CanonicalLabOverlayError("canonical recording identity is ambiguous")
|
||||
return str(matches[0])
|
||||
|
||||
|
||||
def canonical_lab_overlay(
|
||||
result_root: Path,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
recording_id: str,
|
||||
base_generation_sha256: str,
|
||||
jobs_root: Path,
|
||||
cache_root: Path,
|
||||
ffmpeg_path: Path,
|
||||
) -> CanonicalLabOverlayArtifact:
|
||||
"""Return one cached, digest-bound RRD sidecar for a full-route LAB result."""
|
||||
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
jobs = jobs_root.expanduser().resolve(strict=True)
|
||||
ffmpeg = ffmpeg_path.expanduser().resolve(strict=True)
|
||||
cache = cache_root.expanduser().absolute()
|
||||
result_id = str(manifest.get("result_id", ""))
|
||||
result_path = root / "result.json"
|
||||
result_sha256 = _sha256(result_path)
|
||||
if (
|
||||
root.name != result_id
|
||||
or not result_id.startswith("lab-v1-vegetation-shadow-")
|
||||
or len(result_id) != len("lab-v1-vegetation-shadow-") + 64
|
||||
or not recording_id
|
||||
or len(recording_id) > 128
|
||||
or not _is_sha256(base_generation_sha256)
|
||||
or not ffmpeg.is_file()
|
||||
or not os.access(ffmpeg, os.X_OK)
|
||||
):
|
||||
raise CanonicalLabOverlayError("canonical LAB overlay identity is invalid")
|
||||
key = (result_id, recording_id, base_generation_sha256)
|
||||
cached = _memory_cache.get(key)
|
||||
if cached is not None and _artifact_is_regular(cached):
|
||||
return cached
|
||||
|
||||
with _render_lock:
|
||||
cached = _memory_cache.get(key)
|
||||
if cached is not None and _artifact_is_regular(cached):
|
||||
return cached
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
if cache.is_symlink() or not cache.is_dir():
|
||||
raise CanonicalLabOverlayError("canonical LAB overlay cache is invalid")
|
||||
identity = hashlib.sha256(
|
||||
"\0".join(
|
||||
(RENDERER_VERSION, result_sha256, recording_id, base_generation_sha256)
|
||||
).encode()
|
||||
).hexdigest()
|
||||
output = cache / f"{identity}.rrd"
|
||||
sidecar = cache / f"{identity}.json"
|
||||
restored = _restore_cached(
|
||||
output,
|
||||
sidecar,
|
||||
result_id=result_id,
|
||||
result_sha256=result_sha256,
|
||||
recording_id=recording_id,
|
||||
base_generation_sha256=base_generation_sha256,
|
||||
)
|
||||
if restored is not None:
|
||||
_memory_cache[key] = restored
|
||||
return restored
|
||||
|
||||
temporary = cache / f".{identity}.{uuid4().hex}.rrd"
|
||||
proxy: Path | None = None
|
||||
source: Path | None = None
|
||||
try:
|
||||
route = _full_route(manifest)
|
||||
source = _verified_camera_source(root, route, jobs)
|
||||
proxy = _camera_proxy(source, int(route["frame_count"]), ffmpeg, cache)
|
||||
_render_overlay(temporary, root, route, recording_id, proxy)
|
||||
stat = temporary.stat()
|
||||
if stat.st_size < 4 or stat.st_size > MAX_OVERLAY_BYTES:
|
||||
raise CanonicalLabOverlayError("canonical LAB overlay size is invalid")
|
||||
digest = _sha256(temporary)
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, output)
|
||||
_write_json_atomic(
|
||||
sidecar,
|
||||
{
|
||||
"schema_version": "missioncore.canonical-lab-rerun-overlay/v1",
|
||||
"renderer_version": RENDERER_VERSION,
|
||||
"result_id": result_id,
|
||||
"result_sha256": result_sha256,
|
||||
"recording_id": recording_id,
|
||||
"base_generation_sha256": base_generation_sha256,
|
||||
"byte_length": stat.st_size,
|
||||
"sha256": digest,
|
||||
},
|
||||
)
|
||||
artifact = CanonicalLabOverlayArtifact(output, stat.st_size, digest)
|
||||
_memory_cache[key] = artifact
|
||||
return artifact
|
||||
except CanonicalLabOverlayError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise CanonicalLabOverlayError("failed to render canonical LAB overlay") from exc
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
if proxy is not None:
|
||||
proxy.unlink(missing_ok=True)
|
||||
if source is not None:
|
||||
source.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _full_route(manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
route = manifest.get("route_full_review")
|
||||
layers = route.get("layers") if isinstance(route, dict) else None
|
||||
if (
|
||||
not isinstance(route, dict)
|
||||
or route.get("source_id") != "RAVNOVES004TREE"
|
||||
or route.get("frame_count") != 6830
|
||||
or route.get("width") != 800
|
||||
or route.get("height") != 600
|
||||
or not isinstance(layers, dict)
|
||||
or set(layers) != {"city", "vegetation"}
|
||||
):
|
||||
raise CanonicalLabOverlayError("full-route LAB contract is unavailable")
|
||||
return route
|
||||
|
||||
|
||||
def _verified_camera_source(root: Path, route: dict[str, Any], jobs_root: Path) -> Path:
|
||||
source_job_id = route.get("source_job_id")
|
||||
proof = route.get("proofs", {}).get("job") if isinstance(route.get("proofs"), dict) else None
|
||||
if (
|
||||
not isinstance(source_job_id, str)
|
||||
or not source_job_id.startswith("recorded-camera-")
|
||||
or not isinstance(proof, dict)
|
||||
or proof.get("path") != "proofs/job.json"
|
||||
or not _is_sha256(proof.get("sha256"))
|
||||
):
|
||||
raise CanonicalLabOverlayError("camera job binding is invalid")
|
||||
proof_path = root / "proofs" / "job.json"
|
||||
job_root = (jobs_root / source_job_id).resolve(strict=True)
|
||||
job_path = job_root / "job.json"
|
||||
if (
|
||||
not job_root.is_relative_to(jobs_root)
|
||||
or _sha256(proof_path) != proof["sha256"]
|
||||
or _sha256(job_path) != proof["sha256"]
|
||||
):
|
||||
raise CanonicalLabOverlayError("camera job proof changed")
|
||||
job = json.loads(job_path.read_text(encoding="utf-8"))
|
||||
source = job.get("input") if isinstance(job, dict) else None
|
||||
files = source.get("files") if isinstance(source, dict) else None
|
||||
frame_count = int(route["frame_count"])
|
||||
if (
|
||||
not isinstance(source, dict)
|
||||
or source.get("session_id") != route.get("session_id")
|
||||
or source.get("source_id") != "sensor.camera.right"
|
||||
or source.get("segment_count") != frame_count
|
||||
or source.get("byte_length", 0) > MAX_SOURCE_BYTES
|
||||
or not isinstance(files, list)
|
||||
):
|
||||
raise CanonicalLabOverlayError("camera job source contract changed")
|
||||
epoch_prefix = PurePosixPath(
|
||||
"input/camera/sensor.camera.right"
|
||||
) / f"epoch-{source.get('codec_epoch')}"
|
||||
required = [epoch_prefix / "init.mp4"] + [
|
||||
epoch_prefix / "segments" / f"{index}.m4s"
|
||||
for index in range(1, frame_count + 1)
|
||||
]
|
||||
descriptors = {
|
||||
item.get("path"): item
|
||||
for item in files
|
||||
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
||||
}
|
||||
temporary_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".canonical-lab-source-", suffix=".mp4", dir=root.parent
|
||||
)
|
||||
output = Path(temporary_name)
|
||||
total = 0
|
||||
try:
|
||||
with os.fdopen(temporary_descriptor, "wb") as stream:
|
||||
for relative in required:
|
||||
descriptor = descriptors.get(str(relative))
|
||||
path = (job_root / relative).resolve(strict=True)
|
||||
if (
|
||||
descriptor is None
|
||||
or not path.is_relative_to(job_root)
|
||||
or path.is_symlink()
|
||||
or descriptor.get("byte_length") != path.stat().st_size
|
||||
or not _is_sha256(descriptor.get("sha256"))
|
||||
):
|
||||
raise CanonicalLabOverlayError("camera fragment contract changed")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source_stream:
|
||||
while chunk := source_stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
stream.write(chunk)
|
||||
total += len(chunk)
|
||||
if digest.hexdigest() != descriptor["sha256"]:
|
||||
raise CanonicalLabOverlayError("camera fragment digest changed")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
if total <= 0 or total > MAX_SOURCE_BYTES:
|
||||
raise CanonicalLabOverlayError("camera source size is invalid")
|
||||
return output
|
||||
except BaseException:
|
||||
output.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _camera_proxy(source: Path, frame_count: int, ffmpeg: Path, root: Path) -> Path:
|
||||
descriptor, name = tempfile.mkstemp(prefix=".canonical-lab-video-", suffix=".mp4", dir=root)
|
||||
os.close(descriptor)
|
||||
output = Path(name)
|
||||
output.unlink(missing_ok=True)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(ffmpeg),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(source),
|
||||
"-frames:v",
|
||||
str(frame_count),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"28",
|
||||
"-g",
|
||||
"20",
|
||||
"-keyint_min",
|
||||
"20",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=900,
|
||||
)
|
||||
if completed.returncode != 0 or not output.is_file() or output.stat().st_size <= 0:
|
||||
output.unlink(missing_ok=True)
|
||||
raise CanonicalLabOverlayError(
|
||||
f"canonical LAB video proxy failed: {completed.stderr[-1000:]!r}"
|
||||
)
|
||||
os.chmod(output, 0o600)
|
||||
return output
|
||||
|
||||
|
||||
def _render_overlay(
|
||||
output: Path,
|
||||
root: Path,
|
||||
route: dict[str, Any],
|
||||
recording_id: str,
|
||||
proxy: Path,
|
||||
) -> None:
|
||||
frame_times = _frame_times(root, route)
|
||||
layers = route["layers"]
|
||||
archives: dict[str, zipfile.ZipFile] = {}
|
||||
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||
try:
|
||||
recording.set_sinks(rr.FileSink(output, write_footer=True))
|
||||
recording.log(
|
||||
"/perception/camera/image",
|
||||
rr.AssetVideo(path=proxy),
|
||||
static=True,
|
||||
)
|
||||
video = rr.AssetVideo(path=proxy)
|
||||
video_timestamps = video.read_frame_timestamps_nanos()
|
||||
# The sealed fMP4 source has one fragment per LAB tick, but not every
|
||||
# fragment contains a decodable video sample. Preserve the source PTS
|
||||
# in the proxy and bind every LAB tick to the latest available sample.
|
||||
# Re-numbering decoded samples at 10 Hz shortens RAV004 by ~43 seconds
|
||||
# and is exactly the camera/semantics drift this projection prevents.
|
||||
video_references = _video_reference_timestamps(video_timestamps, frame_times)
|
||||
for layer_id in ("city", "vegetation"):
|
||||
layer = layers[layer_id]
|
||||
taxonomy = layer.get("taxonomy")
|
||||
classes = taxonomy.get("classes") if isinstance(taxonomy, dict) else None
|
||||
if not isinstance(classes, list):
|
||||
raise CanonicalLabOverlayError("semantic taxonomy is invalid")
|
||||
context = rr.AnnotationContext(
|
||||
[
|
||||
rr.ClassDescription(
|
||||
info=rr.AnnotationInfo(
|
||||
id=int(item["class_id"]),
|
||||
label=str(item["label"]),
|
||||
color=[*map(int, item["color_rgb"]), 255],
|
||||
)
|
||||
)
|
||||
for item in classes
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
)
|
||||
recording.log(
|
||||
f"/perception/camera/segmentation/{layer_id}",
|
||||
context,
|
||||
static=True,
|
||||
)
|
||||
archive = layer.get("mask_archive")
|
||||
relative = archive.get("path") if isinstance(archive, dict) else None
|
||||
if not isinstance(relative, str):
|
||||
raise CanonicalLabOverlayError("semantic archive is unavailable")
|
||||
path = (root / PurePosixPath(relative)).resolve(strict=True)
|
||||
if not path.is_relative_to(root) or path.is_symlink():
|
||||
raise CanonicalLabOverlayError("semantic archive path is unsafe")
|
||||
archives[layer_id] = zipfile.ZipFile(path)
|
||||
|
||||
for index, timestamp in enumerate(frame_times):
|
||||
recording.set_time(SESSION_TIMELINE, duration=np.timedelta64(timestamp, "ns"))
|
||||
recording.log(
|
||||
"/perception/camera/image",
|
||||
rr.VideoFrameReference(nanoseconds=int(video_references[index])),
|
||||
)
|
||||
masks = {
|
||||
layer_id: _read_mask(archive, index)
|
||||
for layer_id, archive in archives.items()
|
||||
}
|
||||
for layer_id, mask in masks.items():
|
||||
recording.log(
|
||||
f"/perception/camera/segmentation/{layer_id}",
|
||||
rr.SegmentationImage(mask, opacity=0.72, draw_order=1.0),
|
||||
)
|
||||
boxes, labels = semantic_component_boxes(masks["city"], index)
|
||||
if boxes:
|
||||
recording.log(
|
||||
"/perception/camera/detections",
|
||||
rr.Boxes2D(
|
||||
array=boxes,
|
||||
array_format=rr.Box2DFormat.XYXY,
|
||||
labels=labels,
|
||||
show_labels=True,
|
||||
colors=[[255, 210, 55, 255]] * len(boxes),
|
||||
),
|
||||
)
|
||||
else:
|
||||
recording.log(
|
||||
"/perception/camera/detections",
|
||||
rr.Clear(recursive=False),
|
||||
)
|
||||
recording.flush(timeout_sec=300.0)
|
||||
finally:
|
||||
for archive in archives.values():
|
||||
with suppress(Exception):
|
||||
archive.close()
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
|
||||
|
||||
def _frame_times(root: Path, route: dict[str, Any]) -> np.ndarray:
|
||||
descriptor = route.get("timeline")
|
||||
relative = descriptor.get("path") if isinstance(descriptor, dict) else None
|
||||
if not isinstance(relative, str):
|
||||
raise CanonicalLabOverlayError("LAB timeline is unavailable")
|
||||
path = (root / PurePosixPath(relative)).resolve(strict=True)
|
||||
payload = path.read_bytes()
|
||||
if (
|
||||
not path.is_relative_to(root)
|
||||
or path.is_symlink()
|
||||
or descriptor.get("byte_length") != len(payload)
|
||||
or descriptor.get("sha256") != hashlib.sha256(payload).hexdigest()
|
||||
):
|
||||
raise CanonicalLabOverlayError("LAB timeline changed")
|
||||
values = np.frombuffer(payload, dtype="<u8").astype(np.int64, copy=False)
|
||||
if values.shape != (route["frame_count"],) or np.any(np.diff(values) <= 0):
|
||||
raise CanonicalLabOverlayError("LAB timeline order changed")
|
||||
return values
|
||||
|
||||
|
||||
def _video_reference_timestamps(
|
||||
video_timestamps: np.ndarray,
|
||||
frame_times: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Bind every LAB tick to the latest decodable source video sample."""
|
||||
|
||||
relative_frame_times = frame_times - frame_times[0]
|
||||
if (
|
||||
len(video_timestamps) < int(len(frame_times) * 0.9)
|
||||
or np.any(np.diff(video_timestamps) < 0)
|
||||
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1]))
|
||||
> 2_000_000_000
|
||||
):
|
||||
raise CanonicalLabOverlayError("video proxy timeline changed")
|
||||
indices = np.searchsorted(
|
||||
video_timestamps,
|
||||
relative_frame_times,
|
||||
side="right",
|
||||
) - 1
|
||||
return video_timestamps[np.clip(indices, 0, len(video_timestamps) - 1)]
|
||||
|
||||
|
||||
def _read_mask(archive: zipfile.ZipFile, sequence: int) -> np.ndarray:
|
||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||
with Image.open(io.BytesIO(archive.read(member))) as image:
|
||||
mask = np.asarray(image.convert("L"), dtype=np.uint8).copy()
|
||||
if mask.shape != (600, 800):
|
||||
raise CanonicalLabOverlayError("semantic mask dimensions changed")
|
||||
return mask
|
||||
|
||||
|
||||
def semantic_component_boxes(
|
||||
mask: np.ndarray,
|
||||
_sequence: int,
|
||||
) -> tuple[list[list[int]], list[str]]:
|
||||
labels = {
|
||||
1: "person",
|
||||
2: "bicycle",
|
||||
3: "motorcycle",
|
||||
4: "car",
|
||||
5: "heavy vehicle",
|
||||
13: "static obstacle",
|
||||
14: "animal",
|
||||
}
|
||||
candidates: list[tuple[float, list[int], str]] = []
|
||||
for class_id, label in labels.items():
|
||||
minimum_pixels = 80 if class_id == 13 else 24
|
||||
for left, top, right, bottom, pixels in _mask_component_boxes(
|
||||
mask, class_id, minimum_pixels=minimum_pixels
|
||||
)[:12]:
|
||||
score = min(0.99, 0.5 + pixels / 20_000)
|
||||
candidates.append(
|
||||
(score, [left, top, right, bottom], f"{label} · {score:.0%} · semantic-only")
|
||||
)
|
||||
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
|
||||
selected = candidates[:32]
|
||||
return [row[1] for row in selected], [row[2] for row in selected]
|
||||
|
||||
|
||||
def _mask_component_boxes(
|
||||
mask: np.ndarray,
|
||||
class_id: int,
|
||||
*,
|
||||
minimum_pixels: int,
|
||||
) -> list[tuple[int, int, int, int, int]]:
|
||||
"""Return deterministic 8-connected run-length components."""
|
||||
|
||||
if mask.ndim != 2 or minimum_pixels < 1:
|
||||
return []
|
||||
parents: list[int] = []
|
||||
runs: list[tuple[int, int, int, int]] = []
|
||||
|
||||
def root(index: int) -> int:
|
||||
while parents[index] != index:
|
||||
parents[index] = parents[parents[index]]
|
||||
index = parents[index]
|
||||
return index
|
||||
|
||||
def union(left: int, right: int) -> None:
|
||||
left_root = root(left)
|
||||
right_root = root(right)
|
||||
if left_root != right_root:
|
||||
parents[right_root] = left_root
|
||||
|
||||
previous: list[int] = []
|
||||
for row_index, row in enumerate(mask):
|
||||
matches = np.flatnonzero(row == class_id)
|
||||
if matches.size == 0:
|
||||
previous = []
|
||||
continue
|
||||
groups = np.split(matches, np.flatnonzero(np.diff(matches) > 1) + 1)
|
||||
current: list[int] = []
|
||||
previous_cursor = 0
|
||||
for group in groups:
|
||||
start = int(group[0])
|
||||
stop = int(group[-1]) + 1
|
||||
run_index = len(runs)
|
||||
runs.append((row_index, start, stop, stop - start))
|
||||
parents.append(run_index)
|
||||
current.append(run_index)
|
||||
while previous_cursor < len(previous) and runs[previous[previous_cursor]][2] < start:
|
||||
previous_cursor += 1
|
||||
candidate_cursor = previous_cursor
|
||||
while candidate_cursor < len(previous):
|
||||
previous_index = previous[candidate_cursor]
|
||||
_, previous_start, previous_stop, _ = runs[previous_index]
|
||||
if previous_start > stop:
|
||||
break
|
||||
union(run_index, previous_index)
|
||||
candidate_cursor += 1
|
||||
previous = current
|
||||
|
||||
components: dict[int, list[int]] = {}
|
||||
for run_index, (row, start, stop, count) in enumerate(runs):
|
||||
component = components.setdefault(root(run_index), [start, row, stop, row + 1, 0])
|
||||
component[0] = min(component[0], start)
|
||||
component[1] = min(component[1], row)
|
||||
component[2] = max(component[2], stop)
|
||||
component[3] = max(component[3], row + 1)
|
||||
component[4] += count
|
||||
result = [
|
||||
(left, top, right, bottom, count)
|
||||
for left, top, right, bottom, count in components.values()
|
||||
if count >= minimum_pixels and right - left >= 2 and bottom - top >= 3
|
||||
]
|
||||
result.sort(key=lambda box: (-box[4], box[1], box[0]))
|
||||
return result
|
||||
|
||||
|
||||
def _restore_cached(
|
||||
output: Path,
|
||||
sidecar: Path,
|
||||
**identity: str,
|
||||
) -> CanonicalLabOverlayArtifact | None:
|
||||
try:
|
||||
value = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
stat = output.stat()
|
||||
if (
|
||||
output.is_symlink()
|
||||
or sidecar.is_symlink()
|
||||
or value.get("schema_version") != "missioncore.canonical-lab-rerun-overlay/v1"
|
||||
or value.get("renderer_version") != RENDERER_VERSION
|
||||
or any(value.get(key) != expected for key, expected in identity.items())
|
||||
or value.get("byte_length") != stat.st_size
|
||||
or not _is_sha256(value.get("sha256"))
|
||||
or _sha256(output) != value["sha256"]
|
||||
):
|
||||
return None
|
||||
return CanonicalLabOverlayArtifact(output, stat.st_size, value["sha256"])
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _artifact_is_regular(artifact: CanonicalLabOverlayArtifact) -> bool:
|
||||
try:
|
||||
return (
|
||||
not artifact.path.is_symlink()
|
||||
and artifact.path.stat().st_size == artifact.byte_length
|
||||
and _sha256(artifact.path) == artifact.sha256
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, value: dict[str, Any]) -> None:
|
||||
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") as stream:
|
||||
json.dump(value, stream, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _is_sha256(value: object) -> bool:
|
||||
return isinstance(value, str) and len(value) == 64 and all(
|
||||
character in "0123456789abcdef" for character in value
|
||||
)
|
||||
@@ -63,7 +63,7 @@ class _RecordedBlueprintStream:
|
||||
self.view_reset_generation = view_reset_generation
|
||||
self._lock = Lock()
|
||||
self._sequence = 0
|
||||
self._follow_trajectory: bool | None = None
|
||||
self._eye_contract: tuple[bool, bool] | None = None
|
||||
self._closed = False
|
||||
native = bindings.new_blueprint(
|
||||
application_id=application_id,
|
||||
@@ -85,14 +85,13 @@ class _RecordedBlueprintStream:
|
||||
blueprint_factory: Callable[[bool], rrb.Blueprint],
|
||||
*,
|
||||
follow_trajectory: bool,
|
||||
plan_view: bool,
|
||||
) -> bytes:
|
||||
with self._lock:
|
||||
if self._closed:
|
||||
raise RecordedBlueprintError("stable blueprint stream is closed")
|
||||
update_eye_controls = (
|
||||
self._follow_trajectory is None
|
||||
or self._follow_trajectory != follow_trajectory
|
||||
)
|
||||
eye_contract = (follow_trajectory, plan_view)
|
||||
update_eye_controls = self._eye_contract != eye_contract
|
||||
blueprint = blueprint_factory(update_eye_controls)
|
||||
self._blueprint_recording.set_time(
|
||||
"blueprint",
|
||||
@@ -110,7 +109,7 @@ class _RecordedBlueprintStream:
|
||||
payload = self._transport.read(flush=True, flush_timeout_sec=5.0)
|
||||
if not payload:
|
||||
raise RecordedBlueprintError("stable blueprint stream produced no data")
|
||||
self._follow_trajectory = follow_trajectory
|
||||
self._eye_contract = eye_contract
|
||||
return payload
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -165,6 +164,8 @@ def recorded_blueprint(
|
||||
active_view: RecordedView = "spatial",
|
||||
view_reset_generation: Literal[0, 1] = 0,
|
||||
unified_perception: bool = False,
|
||||
semantic_layer: Literal["city", "vegetation"] | None = None,
|
||||
plan_view: bool = False,
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
@@ -202,6 +203,27 @@ def recorded_blueprint(
|
||||
if accumulated_time_ranges is not None:
|
||||
point_overrides.append(accumulated_time_ranges)
|
||||
trajectory_overrides.append(accumulated_time_ranges)
|
||||
selected_semantic_path = (
|
||||
f"/perception/camera/segmentation/{semantic_layer}"
|
||||
if semantic_layer is not None
|
||||
else "/perception/camera/segmentation"
|
||||
)
|
||||
spatial_eye_controls = (
|
||||
rrb.EyeControls3D.from_fields(
|
||||
kind=rrb.Eye3DKind.Orbital,
|
||||
position=[0.0, 0.0, 30.0],
|
||||
look_target=[0.0, 0.0, 0.0],
|
||||
eye_up=[0.0, 1.0, 0.0],
|
||||
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
|
||||
)
|
||||
if plan_view and update_eye_controls
|
||||
else rrb.EyeControls3D.from_fields(
|
||||
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
|
||||
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
|
||||
)
|
||||
if update_eye_controls
|
||||
else None
|
||||
)
|
||||
spatial_view = rrb.Spatial3DView(
|
||||
origin="/world",
|
||||
name="Мир · LiDAR и объекты" if unified_perception else "Пространственная сцена",
|
||||
@@ -232,14 +254,7 @@ def recorded_blueprint(
|
||||
# Keeping the view id stable preserves the current orbit offset when
|
||||
# tracking is toggled. An explicit empty path clears tracking without
|
||||
# overwriting the position/look-target saved by user interaction.
|
||||
eye_controls=(
|
||||
rrb.EyeControls3D.from_fields(
|
||||
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
|
||||
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
|
||||
)
|
||||
if update_eye_controls
|
||||
else None
|
||||
),
|
||||
eye_controls=spatial_eye_controls,
|
||||
)
|
||||
spatial_view.id = (
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID
|
||||
@@ -258,6 +273,12 @@ def recorded_blueprint(
|
||||
"/perception/camera/segmentation": rrb.EntityBehavior(
|
||||
visible=show_segmentation,
|
||||
),
|
||||
"/perception/camera/segmentation/city": rrb.EntityBehavior(
|
||||
visible=show_segmentation and selected_semantic_path.endswith("/city"),
|
||||
),
|
||||
"/perception/camera/segmentation/vegetation": rrb.EntityBehavior(
|
||||
visible=show_segmentation and selected_semantic_path.endswith("/vegetation"),
|
||||
),
|
||||
},
|
||||
)
|
||||
camera_view.id = (
|
||||
@@ -292,14 +313,7 @@ def recorded_blueprint(
|
||||
# native cloud from /world/points.
|
||||
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
eye_controls=(
|
||||
rrb.EyeControls3D.from_fields(
|
||||
kind=rrb.Eye3DKind.Orbital if follow_trajectory else None,
|
||||
tracking_entity="/world/sensor_pose" if follow_trajectory else "",
|
||||
)
|
||||
if update_eye_controls
|
||||
else None
|
||||
),
|
||||
eye_controls=spatial_eye_controls,
|
||||
)
|
||||
perception_3d_view.id = (
|
||||
RECORDED_PERCEPTION_3D_RESET_VIEW_ID
|
||||
@@ -400,6 +414,8 @@ def recorded_blueprint_rrd(
|
||||
active_view: RecordedView = "spatial",
|
||||
view_reset_generation: Literal[0, 1] = 0,
|
||||
unified_perception: bool = False,
|
||||
semantic_layer: Literal["city", "vegetation"] | None = None,
|
||||
plan_view: bool = False,
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
@@ -414,6 +430,8 @@ def recorded_blueprint_rrd(
|
||||
active_view=active_view,
|
||||
view_reset_generation=view_reset_generation,
|
||||
unified_perception=unified_perception,
|
||||
semantic_layer=semantic_layer,
|
||||
plan_view=plan_view,
|
||||
show_detections_2d=show_detections_2d,
|
||||
show_segmentation=show_segmentation,
|
||||
show_cuboids_3d=show_cuboids_3d,
|
||||
@@ -432,6 +450,7 @@ def recorded_blueprint_rrd(
|
||||
).render(
|
||||
build_blueprint,
|
||||
follow_trajectory=follow_trajectory,
|
||||
plan_view=plan_view,
|
||||
)
|
||||
except RecordedBlueprintError:
|
||||
raise
|
||||
|
||||
@@ -149,7 +149,7 @@ class RerunBridge:
|
||||
# of record. Raw MQTT evidence is persisted independently. A large
|
||||
# late-client backlog can block the native SDK and freeze preview.
|
||||
server_memory_limit=LIVE_GRPC_BUFFER_LIMIT,
|
||||
# Rerun 0.34.1 can replay ActivateStore before StoreInfo when an
|
||||
# Rerun transport can replay ActivateStore before StoreInfo when an
|
||||
# evicted buffer is served newest-first, leaving late viewers on the
|
||||
# welcome screen. Preserve protocol order within the bounded cache.
|
||||
newest_first=False,
|
||||
|
||||
@@ -1047,6 +1047,11 @@ app.include_router(
|
||||
if session_recorded_camera_frame_service is not None
|
||||
else None
|
||||
),
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
rerun_overlay_cache_root=(
|
||||
session_store.data_dir / "laboratory-rerun-overlays"
|
||||
),
|
||||
ffmpeg_path=_ffmpeg,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -124,6 +124,8 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
|
||||
view_reset_generation: Literal[0, 1] = 0
|
||||
unified_perception: StrictBool = False
|
||||
semantic_layer: Literal["city", "vegetation"] | None = None
|
||||
plan_view: StrictBool = False
|
||||
show_detections_2d: StrictBool = False
|
||||
show_segmentation: StrictBool = False
|
||||
show_cuboids_3d: StrictBool = False
|
||||
@@ -935,6 +937,8 @@ def build_session_router(
|
||||
active_view=request.active_view,
|
||||
view_reset_generation=request.view_reset_generation,
|
||||
unified_perception=request.unified_perception,
|
||||
semantic_layer=request.semantic_layer,
|
||||
plan_view=request.plan_view,
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
|
||||
@@ -12,13 +12,21 @@ import zipfile
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.laboratory.canonical_rerun_overlay import (
|
||||
CanonicalLabOverlayError,
|
||||
_mask_component_boxes,
|
||||
canonical_lab_overlay,
|
||||
canonical_recording_id,
|
||||
)
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||
from k1link.laboratory.evidence_report import (
|
||||
LaboratoryEvidenceReportError,
|
||||
@@ -34,6 +42,17 @@ from k1link.sessions.canonical_lab_spatial import (
|
||||
RootProvider = Callable[[], Path | None]
|
||||
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
|
||||
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||
|
||||
|
||||
class CanonicalLabRerunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
application_id: Literal["nodedc_mission_core_recorded"]
|
||||
recording_id: str = Field(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
|
||||
)
|
||||
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
||||
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 24
|
||||
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
||||
@@ -57,6 +76,9 @@ def build_vegetation_shadow_lab_router(
|
||||
root_provider: RootProvider = lambda: None,
|
||||
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||
camera_frame_provider: CameraFrameProvider | None = None,
|
||||
jobs_root: Path | None = None,
|
||||
rerun_overlay_cache_root: Path | None = None,
|
||||
ffmpeg_path: Path | None = None,
|
||||
) -> APIRouter:
|
||||
return _build_vegetation_lab_router(
|
||||
prefix="/api/v1/laboratory/vegetation-shadow",
|
||||
@@ -64,6 +86,9 @@ def build_vegetation_shadow_lab_router(
|
||||
root_provider=root_provider,
|
||||
canonical_recording_provider=canonical_recording_provider,
|
||||
camera_frame_provider=camera_frame_provider,
|
||||
jobs_root=jobs_root,
|
||||
rerun_overlay_cache_root=rerun_overlay_cache_root,
|
||||
ffmpeg_path=ffmpeg_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -84,6 +109,9 @@ def _build_vegetation_lab_router(
|
||||
root_provider: RootProvider,
|
||||
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||
camera_frame_provider: CameraFrameProvider | None = None,
|
||||
jobs_root: Path | None = None,
|
||||
rerun_overlay_cache_root: Path | None = None,
|
||||
ffmpeg_path: Path | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(
|
||||
prefix=prefix,
|
||||
@@ -270,6 +298,61 @@ def _build_vegetation_lab_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.post("/{result_id}/canonical-overlay.rrd")
|
||||
async def get_canonical_rerun_overlay(
|
||||
result_id: str,
|
||||
request: CanonicalLabRerunRequest,
|
||||
) -> FileResponse:
|
||||
"""Project LAB-only evidence into the base recording's native clock."""
|
||||
|
||||
if (
|
||||
canonical_recording_provider is None
|
||||
or jobs_root is None
|
||||
or rerun_overlay_cache_root is None
|
||||
or ffmpeg_path is None
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="Canonical LAB Rerun overlay unavailable")
|
||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||
manifest = _read_verified(candidate, definition)
|
||||
route, _ = _full_route_context(candidate, manifest)
|
||||
recording = canonical_recording_provider(str(route["session_id"]))
|
||||
if recording is None:
|
||||
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
||||
recording_path, generation_sha256 = recording
|
||||
try:
|
||||
expected_recording_id = await run_in_threadpool(
|
||||
canonical_recording_id,
|
||||
recording_path,
|
||||
)
|
||||
if request.recording_id != expected_recording_id:
|
||||
raise HTTPException(status_code=412, detail="Canonical recording identity changed")
|
||||
artifact = await run_in_threadpool(
|
||||
canonical_lab_overlay,
|
||||
candidate,
|
||||
manifest,
|
||||
recording_id=request.recording_id,
|
||||
base_generation_sha256=generation_sha256,
|
||||
jobs_root=jobs_root,
|
||||
cache_root=rerun_overlay_cache_root,
|
||||
ffmpeg_path=ffmpeg_path,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except CanonicalLabOverlayError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Canonical LAB Rerun overlay failed verification",
|
||||
) from exc
|
||||
return FileResponse(
|
||||
artifact.path,
|
||||
media_type="application/vnd.rerun.rrd",
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"{artifact.sha256}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/{result_id}/timeline")
|
||||
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
|
||||
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||
@@ -811,80 +894,6 @@ def _semantic_component_proposals_cached(
|
||||
return tuple(proposals[:32])
|
||||
|
||||
|
||||
def _mask_component_boxes(
|
||||
mask: np.ndarray,
|
||||
class_id: int,
|
||||
*,
|
||||
minimum_pixels: int,
|
||||
) -> list[tuple[int, int, int, int, int]]:
|
||||
"""Return 8-connected run-length components without an OpenCV dependency."""
|
||||
|
||||
if mask.ndim != 2 or minimum_pixels < 1:
|
||||
return []
|
||||
parents: list[int] = []
|
||||
runs: list[tuple[int, int, int, int]] = []
|
||||
|
||||
def root(index: int) -> int:
|
||||
while parents[index] != index:
|
||||
parents[index] = parents[parents[index]]
|
||||
index = parents[index]
|
||||
return index
|
||||
|
||||
def union(left: int, right: int) -> None:
|
||||
left_root = root(left)
|
||||
right_root = root(right)
|
||||
if left_root != right_root:
|
||||
parents[right_root] = left_root
|
||||
|
||||
previous: list[int] = []
|
||||
for row_index, row in enumerate(mask):
|
||||
matches = np.flatnonzero(row == class_id)
|
||||
if matches.size == 0:
|
||||
previous = []
|
||||
continue
|
||||
split_at = np.flatnonzero(np.diff(matches) > 1) + 1
|
||||
groups = np.split(matches, split_at)
|
||||
current: list[int] = []
|
||||
previous_cursor = 0
|
||||
for group in groups:
|
||||
start = int(group[0])
|
||||
stop = int(group[-1]) + 1
|
||||
run_index = len(runs)
|
||||
runs.append((row_index, start, stop, stop - start))
|
||||
parents.append(run_index)
|
||||
current.append(run_index)
|
||||
while (
|
||||
previous_cursor < len(previous)
|
||||
and runs[previous[previous_cursor]][2] < start
|
||||
):
|
||||
previous_cursor += 1
|
||||
candidate_cursor = previous_cursor
|
||||
while candidate_cursor < len(previous):
|
||||
previous_index = previous[candidate_cursor]
|
||||
_, previous_start, previous_stop, _ = runs[previous_index]
|
||||
if previous_start > stop:
|
||||
break
|
||||
union(run_index, previous_index)
|
||||
candidate_cursor += 1
|
||||
previous = current
|
||||
|
||||
components: dict[int, list[int]] = {}
|
||||
for run_index, (row, start, stop, count) in enumerate(runs):
|
||||
component = components.setdefault(root(run_index), [start, row, stop, row + 1, 0])
|
||||
component[0] = min(component[0], start)
|
||||
component[1] = min(component[1], row)
|
||||
component[2] = max(component[2], stop)
|
||||
component[3] = max(component[3], row + 1)
|
||||
component[4] += count
|
||||
result = [
|
||||
(left, top, right, bottom, count)
|
||||
for left, top, right, bottom, count in components.values()
|
||||
if count >= minimum_pixels and right - left >= 2 and bottom - top >= 3
|
||||
]
|
||||
result.sort(key=lambda box: (-box[4], box[1], box[0]))
|
||||
return result
|
||||
|
||||
|
||||
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
|
||||
before = path.stat()
|
||||
with np.load(path, allow_pickle=False) as archive:
|
||||
|
||||
@@ -18,6 +18,11 @@ import k1link.laboratory.vegetation_policy_review as policy_review_module
|
||||
import k1link.laboratory.vegetation_policy_video as policy_video_module
|
||||
import k1link.laboratory.vegetation_shadow_lab as vegetation_lab_module
|
||||
from k1link.laboratory import LaboratoryEvidenceRegistry
|
||||
from k1link.laboratory.canonical_rerun_overlay import (
|
||||
CanonicalLabOverlayArtifact,
|
||||
_artifact_is_regular,
|
||||
_video_reference_timestamps,
|
||||
)
|
||||
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
||||
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
|
||||
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
||||
@@ -43,6 +48,47 @@ def test_semantic_component_boxes_keep_distinct_objects_separate() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_canonical_video_references_hold_only_missing_source_samples() -> None:
|
||||
session_times = np.arange(10, dtype=np.int64) * 100_000_000 + 39_000_000_000
|
||||
video_times = np.array(
|
||||
[0, 100, 200, 300, 400, 500, 600, 700, 900],
|
||||
dtype=np.int64,
|
||||
) * 1_000_000
|
||||
|
||||
references = _video_reference_timestamps(video_times, session_times)
|
||||
|
||||
assert references.tolist() == [
|
||||
0,
|
||||
100_000_000,
|
||||
200_000_000,
|
||||
300_000_000,
|
||||
400_000_000,
|
||||
500_000_000,
|
||||
600_000_000,
|
||||
700_000_000,
|
||||
700_000_000,
|
||||
900_000_000,
|
||||
]
|
||||
|
||||
|
||||
def test_canonical_overlay_memory_cache_rejects_same_size_tampering(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
path = tmp_path / "overlay.rrd"
|
||||
path.write_bytes(b"RRF2-original")
|
||||
artifact = CanonicalLabOverlayArtifact(
|
||||
path=path,
|
||||
byte_length=path.stat().st_size,
|
||||
sha256=_sha256(path),
|
||||
)
|
||||
assert _artifact_is_regular(artifact)
|
||||
|
||||
path.write_bytes(b"RRF2-tampered")
|
||||
|
||||
assert path.stat().st_size == artifact.byte_length
|
||||
assert not _artifact_is_regular(artifact)
|
||||
|
||||
|
||||
def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None:
|
||||
points = np.arange(18, dtype="<f4").reshape(6, 3)
|
||||
descriptor = _canonical_route_playback_chunk_descriptor(
|
||||
|
||||
@@ -355,7 +355,7 @@ requires-dist = [
|
||||
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||
{ name = "pillow", specifier = ">=12,<13" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||
{ name = "rerun-sdk", specifier = "==0.34.1" },
|
||||
{ name = "rerun-sdk", specifier = "==0.36.3" },
|
||||
{ name = "rich", specifier = ">=13.9,<15" },
|
||||
{ name = "typer", specifier = ">=0.15,<1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35,<1" },
|
||||
@@ -618,7 +618,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "rerun-sdk"
|
||||
version = "0.34.1"
|
||||
version = "0.36.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -629,10 +629,10 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/37/41422eca73b5f933872ad073d17034f47ccac730fc4a29b9064d73c74424/rerun_sdk-0.34.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:32d3fb3e9f46eb84433427dc6add6ef7508bbd36295dcc80d693dccacf936e6b", size = 133584987, upload-time = "2026-07-07T17:43:14.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/f7/c2e5097f0138a3c4d70e2fe1c24781fac9aec351324c2ea5b15f4d6971b8/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:57585f9c77af6d8a5ee0342bd79b87431b632bf7158e9b8dd4756d6ccd7b7feb", size = 142889929, upload-time = "2026-07-07T17:43:20.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/fa/f87899a8c0cc36901d32069072340b894654fa6f3b3a2a74913565c3661a/rerun_sdk-0.34.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:60c35adb49f04bd64bf1a426b59f61aeee8312fa7cc2624878b43186e817b2d4", size = 147505605, upload-time = "2026-07-07T17:43:25.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3a/07125af48ef024c573acadf1bfedd406bcc838ffa1af0d7b20bd79895267/rerun_sdk-0.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:3448b34d385994a32caaa4e3ffd42b69069ab7791382d8936089e4928ca24cd6", size = 126554967, upload-time = "2026-07-07T17:43:30.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ff/e8ce81a4fa451f232abb10f0737c5fa8c812a4b2979bda2f655c58c478eb/rerun_sdk-0.36.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:804067d6e71747f8461104988be82543b318b583a2837935558a5e79a5e9befd", size = 147513390, upload-time = "2026-08-24T18:59:12.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/0d/a44bccfa279d043929d595a97c220ce3151dd2ef39d6b0477abfa6abc5c0/rerun_sdk-0.36.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:954d8980e15e71245ac91ba7120bbaeb624df2bd7a47bbbb8856fd45174d2521", size = 157793360, upload-time = "2026-08-24T18:59:20.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/30/3dae429550a667bc0536bd419864c8574de908557aaadcc136082cb6f17c/rerun_sdk-0.36.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:287059b7154bf3881f5b32035f5772d0556d55a0a894650fb74a2605fb39afbe", size = 163018185, upload-time = "2026-08-24T18:59:30.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/97/3ea34c7e892474d940759fe18045fe29e245ac5c5b82e526088c388ba854/rerun_sdk-0.36.3-cp310-abi3-win_amd64.whl", hash = "sha256:f84441a3e1d1d679f6fe5a319cac86ccc3992f8f88bfa72517f76d8b45bf066c", size = 140659012, upload-time = "2026-08-24T18:59:37.699Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user