feat(viewer): add recorded point colors and trajectory follow
This commit is contained in:
parent
9f712bf353
commit
ecd95a22f0
|
|
@ -201,18 +201,6 @@ export default function App() {
|
|||
const recordedSessionAdmission = useRecordedSessionAdmission(
|
||||
replayActive ? recordedReplay : null,
|
||||
);
|
||||
const displayPaletteOptions = replayActive
|
||||
? paletteOptions.map((option) => {
|
||||
if (option.value === "turbo") {
|
||||
return {
|
||||
...option,
|
||||
label: "Цвет записи",
|
||||
description: "Вернуть цвета, сохранённые внутри RRD",
|
||||
};
|
||||
}
|
||||
return option.value === "custom" ? option : { ...option, disabled: true };
|
||||
})
|
||||
: paletteOptions;
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayActive;
|
||||
|
||||
|
|
@ -954,7 +942,6 @@ export default function App() {
|
|||
label="Атрибут цвета"
|
||||
value={displayDraft.colorMode}
|
||||
options={colorModeOptions}
|
||||
disabled={replayActive}
|
||||
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
||||
/>
|
||||
</ControlRow>
|
||||
|
|
@ -965,12 +952,12 @@ export default function App() {
|
|||
variant="split"
|
||||
label="Палитра"
|
||||
value={displayDraft.palette}
|
||||
options={displayPaletteOptions}
|
||||
options={paletteOptions}
|
||||
onChange={(palette) => stageDisplayPatch({ palette })}
|
||||
/>
|
||||
</ControlRow>
|
||||
</div>
|
||||
{displayDraft.palette === "custom" ? (
|
||||
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
|
||||
<div
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
|
|
@ -988,8 +975,8 @@ export default function App() {
|
|||
) : null}
|
||||
{replayActive ? (
|
||||
<p className="scene-window-note">
|
||||
В архиве градиентные цвета уже записаны в RRD. Без переэкспорта
|
||||
можно вернуть цвет записи или назначить один свой цвет.
|
||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ export interface RecordedPerceptionLoadState {
|
|||
message: string;
|
||||
}
|
||||
|
||||
export type RecordedPointColorLoadState = Pick<
|
||||
RecordedPerceptionLoadState,
|
||||
"phase" | "receivedBytes" | "totalBytes" | "progress" | "message"
|
||||
>;
|
||||
|
||||
export interface RecordedPerceptionLayers {
|
||||
enabled: boolean;
|
||||
detections2d: boolean;
|
||||
|
|
@ -75,15 +80,18 @@ export interface RerunViewportProps {
|
|||
| "showPoints"
|
||||
| "showTrajectory"
|
||||
| "pointSize"
|
||||
| "colorMode"
|
||||
| "palette"
|
||||
| "customColor"
|
||||
>;
|
||||
recordedView?: RecordedRerunView;
|
||||
recordedViewResetGeneration?: 0 | 1;
|
||||
recordedFollowTrajectory?: boolean;
|
||||
recordedPerceptionLayers?: RecordedPerceptionLayers;
|
||||
recordedPerceptionRetryGeneration?: number;
|
||||
lockPerceptionCameraInteraction?: boolean;
|
||||
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
|
||||
onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void;
|
||||
}
|
||||
|
||||
export interface RecordedRrdArtifactDescriptor {
|
||||
|
|
@ -110,6 +118,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 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;
|
||||
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
||||
|
|
@ -117,6 +126,16 @@ const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
|
|||
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
|
||||
const RECORDED_OPEN_GRACE_MS = 30_000;
|
||||
const RECORDED_OPEN_MIN_BYTES_PER_SECOND = 2 * 1024 * 1024;
|
||||
const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
|
||||
|
||||
export function recordedPointColorKey(
|
||||
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
|
||||
): string {
|
||||
const custom = settings.palette === "custom" || settings.colorMode === "class"
|
||||
? settings.customColor.toLowerCase()
|
||||
: "-";
|
||||
return `${settings.colorMode}|${settings.palette}|${custom}`;
|
||||
}
|
||||
|
||||
export function recordedOpenWatchdogTimeoutMs(byteLength: number): number {
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength < 4) {
|
||||
|
|
@ -459,6 +478,84 @@ export function resolveRecordedPerceptionUrl(sourceUrl: string, origin: string):
|
|||
return endpoint.origin === base.origin ? endpoint.href : null;
|
||||
}
|
||||
|
||||
export function resolveRecordedPointColorsUrl(sourceUrl: string, origin: string): string | null {
|
||||
const normalized = sourceUrl.trim();
|
||||
if (!RECORDED_RRD_PATH.test(normalized)) return null;
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(
|
||||
normalized.replace(/\/recording\.rrd$/, "/point-colors.rrd"),
|
||||
`${base.origin}/`,
|
||||
);
|
||||
return endpoint.origin === base.origin ? endpoint.href : null;
|
||||
}
|
||||
|
||||
export async function fetchRecordedPointColorsRrd(
|
||||
endpointUrl: string,
|
||||
settings: Pick<SceneSettings, "colorMode" | "palette" | "customColor">,
|
||||
identity: RecordedRerunIdentity,
|
||||
{
|
||||
origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
!RECORDED_POINT_COLORS_PATH.test(endpoint.pathname) ||
|
||||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
|
||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
||||
) {
|
||||
throw new Error("Unsafe recorded point-color request");
|
||||
}
|
||||
const response = await fetcher(endpoint.href, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/vnd.rerun.rrd",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
application_id: identity.applicationId,
|
||||
recording_id: identity.recordingId,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
custom_color: settings.customColor,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const declaredLength = Number(response.headers.get("Content-Length"));
|
||||
if (
|
||||
!response.ok ||
|
||||
contentType !== "application/vnd.rerun.rrd" ||
|
||||
(Number.isFinite(declaredLength) && declaredLength < 4)
|
||||
) {
|
||||
throw new Error("Invalid recorded point-color response");
|
||||
}
|
||||
const payload = new Uint8Array(await response.arrayBuffer());
|
||||
if (
|
||||
payload.byteLength < 4 ||
|
||||
payload[0] !== 0x52 ||
|
||||
payload[1] !== 0x52 ||
|
||||
payload[2] !== 0x46 ||
|
||||
payload[3] !== 0x32
|
||||
) {
|
||||
throw new Error("Invalid recorded point-color RRD");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchRecordedBlueprintRrd(
|
||||
endpointUrl: string,
|
||||
settings: Pick<
|
||||
|
|
@ -468,6 +565,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||
| "showPoints"
|
||||
| "showTrajectory"
|
||||
| "pointSize"
|
||||
| "colorMode"
|
||||
| "palette"
|
||||
| "customColor"
|
||||
>,
|
||||
|
|
@ -478,6 +576,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||
signal,
|
||||
activeView = "spatial",
|
||||
viewResetGeneration = 0,
|
||||
followTrajectory = false,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
|
|
@ -491,6 +590,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||
signal?: AbortSignal;
|
||||
activeView?: RecordedRerunView;
|
||||
viewResetGeneration?: 0 | 1;
|
||||
followTrajectory?: boolean;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
|
|
@ -508,6 +608,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||
!Number.isFinite(settings.pointSize) ||
|
||||
settings.pointSize < 0.1 ||
|
||||
settings.pointSize > 32 ||
|
||||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
|
||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
!["spatial", "perception", "perception3d", "metrics"].includes(activeView) ||
|
||||
|
|
@ -540,10 +641,12 @@ export async function fetchRecordedBlueprintRrd(
|
|||
show_points: settings.showPoints,
|
||||
show_trajectory: settings.showTrajectory,
|
||||
point_size: settings.pointSize,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
custom_color: settings.customColor,
|
||||
active_view: activeView,
|
||||
view_reset_generation: viewResetGeneration,
|
||||
follow_trajectory: followTrajectory,
|
||||
unified_perception:
|
||||
perceptionLayers.detections2d || perceptionLayers.segmentation,
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
|
|
@ -679,6 +782,7 @@ export function RerunViewport({
|
|||
sceneSettings,
|
||||
recordedView = "spatial",
|
||||
recordedViewResetGeneration = 0,
|
||||
recordedFollowTrajectory = false,
|
||||
recordedPerceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
|
|
@ -688,6 +792,7 @@ export function RerunViewport({
|
|||
recordedPerceptionRetryGeneration = 0,
|
||||
lockPerceptionCameraInteraction = false,
|
||||
onPerceptionLoadChange,
|
||||
onPointColorLoadChange,
|
||||
}: RerunViewportProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
|
|
@ -696,6 +801,7 @@ export function RerunViewport({
|
|||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const appliedPointColorKeyRef = useRef<string | null>(null);
|
||||
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
||||
const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", ""));
|
||||
const presentationGateRef = useRef(presentationGate);
|
||||
|
|
@ -708,6 +814,9 @@ export function RerunViewport({
|
|||
const recordedPerceptionUrl = sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPointColorsUrl = sourceUrl
|
||||
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const presentationStatus = rerunPresentationStatus(
|
||||
status,
|
||||
presentationGate,
|
||||
|
|
@ -822,6 +931,7 @@ export function RerunViewport({
|
|||
};
|
||||
|
||||
host.replaceChildren();
|
||||
appliedPointColorKeyRef.current = null;
|
||||
setStatus("loading");
|
||||
setRecordingBufferProgress(null);
|
||||
onStatusChange?.("loading");
|
||||
|
|
@ -1213,7 +1323,7 @@ export function RerunViewport({
|
|||
autoplayWhenReady,
|
||||
expectedTimelineEndSeconds,
|
||||
expectedTimelineStartSeconds,
|
||||
followLive,
|
||||
followLive,
|
||||
initialPlaybackStartSeconds,
|
||||
onPlaybackChange,
|
||||
onPlaybackControllerChange,
|
||||
|
|
@ -1231,6 +1341,10 @@ export function RerunViewport({
|
|||
loadedPerceptionChannelRef.current = null;
|
||||
}, [recordedPerceptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
appliedPointColorKeyRef.current = null;
|
||||
}, [recordedPointColorsUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedPerceptionUrl) return;
|
||||
const active = perceptionChannelRef.current;
|
||||
|
|
@ -1327,6 +1441,89 @@ export function RerunViewport({
|
|||
recordedPerceptionUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedPointColorsUrl || !sceneSettings) return;
|
||||
const active = blueprintChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
if (
|
||||
!active ||
|
||||
!identity ||
|
||||
!active.channel.ready
|
||||
) return;
|
||||
const desiredKey = recordedPointColorKey(sceneSettings);
|
||||
if (
|
||||
appliedPointColorKeyRef.current === null &&
|
||||
desiredKey === RECORDED_BASE_POINT_COLOR_KEY
|
||||
) {
|
||||
appliedPointColorKeyRef.current = desiredKey;
|
||||
onPointColorLoadChange?.({
|
||||
phase: "ready",
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
progress: 1,
|
||||
message: "Цвет записи готов.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (appliedPointColorKeyRef.current === desiredKey) {
|
||||
onPointColorLoadChange?.({
|
||||
phase: "ready",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: 1,
|
||||
message: "Окраска точек готова.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const abort = new AbortController();
|
||||
onPointColorLoadChange?.({
|
||||
phase: "loading",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "Готовим архивную окраску точек.",
|
||||
});
|
||||
void fetchRecordedPointColorsRrd(recordedPointColorsUrl, sceneSettings, identity, {
|
||||
origin: window.location.origin,
|
||||
signal: abort.signal,
|
||||
}).then((payload) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
blueprintChannelRef.current !== active ||
|
||||
recordedIdentityRef.current !== identity ||
|
||||
!active.channel.ready
|
||||
) {
|
||||
return;
|
||||
}
|
||||
active.channel.send_rrd(payload);
|
||||
appliedPointColorKeyRef.current = desiredKey;
|
||||
onPointColorLoadChange?.({
|
||||
phase: "ready",
|
||||
receivedBytes: payload.byteLength,
|
||||
totalBytes: payload.byteLength,
|
||||
progress: 1,
|
||||
message: "Окраска точек готова.",
|
||||
});
|
||||
}).catch(() => {
|
||||
if (abort.signal.aborted) return;
|
||||
onPointColorLoadChange?.({
|
||||
phase: "error",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "Окраска точек не загрузилась.",
|
||||
});
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [
|
||||
blueprintChannelRevision,
|
||||
onPointColorLoadChange,
|
||||
recordedPointColorsUrl,
|
||||
sceneSettings?.colorMode,
|
||||
sceneSettings?.customColor,
|
||||
sceneSettings?.palette,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedBlueprintUrl || !sceneSettings) return;
|
||||
const active = blueprintChannelRef.current;
|
||||
|
|
@ -1344,6 +1541,7 @@ export function RerunViewport({
|
|||
signal: abort.signal,
|
||||
activeView: recordedView,
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: recordedFollowTrajectory,
|
||||
perceptionLayers: recordedPerceptionLayers,
|
||||
}).then((payload) => {
|
||||
if (
|
||||
|
|
@ -1365,12 +1563,14 @@ export function RerunViewport({
|
|||
recordedBlueprintUrl,
|
||||
recordedView,
|
||||
recordedViewResetGeneration,
|
||||
recordedFollowTrajectory,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionLayers.detections2d,
|
||||
recordedPerceptionLayers.segmentation,
|
||||
recordedPerceptionLayers.cuboids3d,
|
||||
sceneSettings?.accumulationSeconds,
|
||||
sceneSettings?.customColor,
|
||||
sceneSettings?.colorMode,
|
||||
sceneSettings?.palette,
|
||||
sceneSettings?.pointSize,
|
||||
sceneSettings?.showPoints,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
type RecordedPerceptionLoadState,
|
||||
type RecordedPointColorLoadState,
|
||||
} from "../components/RerunViewport";
|
||||
import {
|
||||
capabilityStatusLabel,
|
||||
|
|
@ -308,6 +309,7 @@ function SpatialWorkspace({
|
|||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
|
||||
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
|
|
@ -316,6 +318,13 @@ function SpatialWorkspace({
|
|||
message: "",
|
||||
});
|
||||
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
|
||||
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||
|
|
@ -437,6 +446,9 @@ function SpatialWorkspace({
|
|||
setShowCuboids3d(false);
|
||||
}
|
||||
}, []);
|
||||
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
|
||||
setPointColorLoad(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPerceptionLoad({
|
||||
|
|
@ -451,6 +463,14 @@ function SpatialWorkspace({
|
|||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setRecordedViewResetGeneration(0);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [recordedSource, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -470,6 +490,14 @@ function SpatialWorkspace({
|
|||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -586,6 +614,29 @@ function SpatialWorkspace({
|
|||
Повторить AI
|
||||
</button>
|
||||
) : null}
|
||||
{recordedSource && pointColorLoad.phase === "loading" ? (
|
||||
<div className="spatial-toolbar__perception-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{recordedSource && pointColorLoad.phase === "error" ? (
|
||||
<div className="spatial-toolbar__perception-status" role="status">
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={followRecordedTrajectory ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={followRecordedTrajectory}
|
||||
title="Привязать orbital-пивот к текущему положению рига"
|
||||
onClick={() => setFollowRecordedTrajectory((current) => !current)}
|
||||
>
|
||||
Следовать
|
||||
</Button>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
|
|
@ -630,6 +681,7 @@ function SpatialWorkspace({
|
|||
initialPlaybackStartSeconds={initialRecordedPlaybackStartSeconds}
|
||||
sceneSettings={sceneSettings}
|
||||
recordedViewResetGeneration={recordedViewResetGeneration}
|
||||
recordedFollowTrajectory={followRecordedTrajectory}
|
||||
recordedPerceptionLayers={{
|
||||
enabled:
|
||||
recordedPerceptionSupported &&
|
||||
|
|
@ -642,6 +694,7 @@ function SpatialWorkspace({
|
|||
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
|
||||
lockPerceptionCameraInteraction={unifiedPerception}
|
||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||
onPointColorLoadChange={onPointColorLoadChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ let resolveRecordedBlueprintUrl;
|
|||
let fetchRecordedBlueprintRrd;
|
||||
let resolveRecordedPerceptionUrl;
|
||||
let fetchRecordedPerceptionRrd;
|
||||
let resolveRecordedPointColorsUrl;
|
||||
let fetchRecordedPointColorsRrd;
|
||||
let recordedPointColorKey;
|
||||
let isRecordedPlaybackFullyBuffered;
|
||||
let recordedObservationSources;
|
||||
let selectRecordedMediaEpoch;
|
||||
|
|
@ -66,6 +69,9 @@ before(async () => {
|
|||
fetchRecordedBlueprintRrd,
|
||||
resolveRecordedPerceptionUrl,
|
||||
fetchRecordedPerceptionRrd,
|
||||
resolveRecordedPointColorsUrl,
|
||||
fetchRecordedPointColorsRrd,
|
||||
recordedPointColorKey,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
|
|
@ -263,6 +269,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
colorMode: "height",
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
|
|
@ -272,6 +279,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
blueprintSessionId: "a".repeat(32),
|
||||
activeView: "perception3d",
|
||||
viewResetGeneration: 1,
|
||||
followTrajectory: true,
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
detections2d: true,
|
||||
|
|
@ -299,10 +307,12 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
show_points: true,
|
||||
show_trajectory: false,
|
||||
point_size: 4.5,
|
||||
color_mode: "height",
|
||||
palette: "custom",
|
||||
custom_color: "#35d7c1",
|
||||
active_view: "perception3d",
|
||||
view_reset_generation: 1,
|
||||
follow_trajectory: true,
|
||||
unified_perception: true,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
|
|
@ -317,6 +327,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
pointSize: 2.5,
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#ffffff",
|
||||
},
|
||||
|
|
@ -351,6 +362,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
showPoints: true,
|
||||
showTrajectory: false,
|
||||
pointSize: 4.5,
|
||||
colorMode: "height",
|
||||
palette: "custom",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
|
|
@ -365,6 +377,73 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
);
|
||||
});
|
||||
|
||||
test("recorded point colors use one strict same-origin component overlay", async () => {
|
||||
const endpoint = resolveRecordedPointColorsUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
);
|
||||
assert.equal(
|
||||
endpoint,
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/point-colors.rrd",
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedPointColorsUrl(
|
||||
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
recordedPointColorKey({
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#112233",
|
||||
}),
|
||||
"intensity|turbo|-",
|
||||
);
|
||||
assert.equal(
|
||||
recordedPointColorKey({
|
||||
colorMode: "class",
|
||||
palette: "turbo",
|
||||
customColor: "#112233",
|
||||
}),
|
||||
"class|turbo|#112233",
|
||||
);
|
||||
|
||||
const calls = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedPointColorsRrd(
|
||||
endpoint,
|
||||
{
|
||||
colorMode: "distance",
|
||||
palette: "viridis",
|
||||
customColor: "#35d7c1",
|
||||
},
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": String(payload.byteLength),
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual([...result], [...payload]);
|
||||
assert.deepEqual(calls[0].body, {
|
||||
application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording-001",
|
||||
color_mode: "distance",
|
||||
palette: "viridis",
|
||||
custom_color: "#35d7c1",
|
||||
});
|
||||
});
|
||||
|
||||
test("recorded perception fetch admits one complete same-origin RRD or no layer", async () => {
|
||||
const endpoint = resolveRecordedPerceptionUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ from k1link.device_plugins.xgrids_k1.archive import (
|
|||
LegacySessionCandidate,
|
||||
discover_legacy_viewer_sessions,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.recorded_point_colors import (
|
||||
RecordedPointColorOverlayStore,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||
RrdExportCancelled,
|
||||
RrdExportError,
|
||||
|
|
@ -45,6 +48,7 @@ def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeCont
|
|||
resolve_missioncore_evidence_dir(repository_root),
|
||||
),
|
||||
)
|
||||
point_colors = RecordedPointColorOverlayStore()
|
||||
return ObservationRuntimeContribution(
|
||||
archives=tuple(
|
||||
ObservationArchiveSource(
|
||||
|
|
@ -57,6 +61,7 @@ def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeCont
|
|||
for archive_id, root in roots
|
||||
),
|
||||
recording_exporter=_export_recording,
|
||||
point_color_renderer=point_colors.render,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,434 @@
|
|||
"""On-demand point-color overlays for prepared K1 recordings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView, NormalizationError
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||
CaptureFormatError,
|
||||
read_capture_clock_envelope,
|
||||
read_capture_clock_origin,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||
APPLICATION_ID,
|
||||
CAPTURE_TIMELINE,
|
||||
SESSION_TIMELINE,
|
||||
_recorded_view_points,
|
||||
_should_publish_recorded_point_frame,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
from k1link.sessions import ReplayCommand
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings, _point_colors
|
||||
|
||||
_POINT_TOPICS = frozenset({"RealtimePointcloud", "lixel/application/report/lio_pcl"})
|
||||
_MAX_METADATA_LINE_BYTES = 1024 * 1024
|
||||
_MAX_PAYLOAD_BYTES = 256 * 1024 * 1024
|
||||
_DEFAULT_INDEX_CACHE_BYTES = 384 * 1024 * 1024
|
||||
_DEFAULT_PAYLOAD_CACHE_BYTES = 192 * 1024 * 1024
|
||||
|
||||
|
||||
class RecordedPointColorError(RuntimeError):
|
||||
"""A recorded point-color overlay could not be prepared safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PointColorFrame:
|
||||
sequence: int
|
||||
session_time_ns: int
|
||||
capture_time_ns: int
|
||||
positions: np.ndarray
|
||||
intensities: np.ndarray
|
||||
rgb: np.ndarray | None
|
||||
|
||||
@property
|
||||
def byte_length(self) -> int:
|
||||
return (
|
||||
int(self.positions.nbytes)
|
||||
+ int(self.intensities.nbytes)
|
||||
+ (0 if self.rgb is None else int(self.rgb.nbytes))
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PointColorIndex:
|
||||
source_identity: tuple[object, ...]
|
||||
frames: tuple[_PointColorFrame, ...]
|
||||
byte_length: int
|
||||
|
||||
|
||||
class RecordedPointColorOverlayStore:
|
||||
"""Build small color-only RRD streams and retain bounded reusable inputs.
|
||||
|
||||
The base operator RRD already owns point positions. These overlays log only
|
||||
``Points3D:colors`` at the exact same session timestamps, so changing a
|
||||
palette never duplicates the point geometry, trajectory, video, or AI data.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
index_cache_bytes: int = _DEFAULT_INDEX_CACHE_BYTES,
|
||||
payload_cache_bytes: int = _DEFAULT_PAYLOAD_CACHE_BYTES,
|
||||
) -> None:
|
||||
if index_cache_bytes < 0 or payload_cache_bytes < 0:
|
||||
raise ValueError("recorded color cache budgets must be non-negative")
|
||||
self._index_cache_bytes = index_cache_bytes
|
||||
self._payload_cache_bytes = payload_cache_bytes
|
||||
self._lock = threading.RLock()
|
||||
self._session_locks: dict[str, threading.Lock] = {}
|
||||
self._indexes: OrderedDict[tuple[object, ...], _PointColorIndex] = OrderedDict()
|
||||
self._index_bytes = 0
|
||||
self._payloads: OrderedDict[tuple[object, ...], bytes] = OrderedDict()
|
||||
self._payload_bytes = 0
|
||||
|
||||
def render(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"],
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"],
|
||||
custom_color: str,
|
||||
) -> bytes:
|
||||
if command.plugin_id != "nodedc.device.xgrids-lixelkity-k1":
|
||||
raise RecordedPointColorError("recorded color provider received another plugin")
|
||||
if application_id != APPLICATION_ID:
|
||||
raise ValueError("recorded color application id is invalid")
|
||||
if not recording_id or len(recording_id) > 128:
|
||||
raise ValueError("recorded color recording id is invalid")
|
||||
|
||||
source = command.primary_artifact.path.expanduser().resolve()
|
||||
metadata = _artifact_path(command, "raw-transport-index")
|
||||
capture_clock = _artifact_path(command, "raw-transport-clock")
|
||||
capture_clock_origin = _artifact_path(command, "raw-transport-clock-origin")
|
||||
source_identity = _source_identity(
|
||||
source,
|
||||
metadata,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
)
|
||||
settings = RerunSceneSettings(
|
||||
color_mode=color_mode,
|
||||
palette=palette,
|
||||
custom_color=custom_color,
|
||||
)
|
||||
settings_key = (
|
||||
color_mode,
|
||||
palette,
|
||||
(
|
||||
custom_color.casefold()
|
||||
if palette == "custom" or color_mode == "class"
|
||||
else "-"
|
||||
),
|
||||
)
|
||||
payload_key = (*source_identity, application_id, recording_id, *settings_key)
|
||||
|
||||
with self._lock:
|
||||
cached_payload = self._payloads.get(payload_key)
|
||||
if cached_payload is not None:
|
||||
self._payloads.move_to_end(payload_key)
|
||||
return cached_payload
|
||||
session_lock = self._session_locks.setdefault(command.session_id, threading.Lock())
|
||||
|
||||
with session_lock:
|
||||
with self._lock:
|
||||
cached_payload = self._payloads.get(payload_key)
|
||||
if cached_payload is not None:
|
||||
self._payloads.move_to_end(payload_key)
|
||||
return cached_payload
|
||||
index = self._index(
|
||||
source_identity,
|
||||
source,
|
||||
metadata,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
)
|
||||
payload = _render_color_overlay(
|
||||
index.frames,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
settings=settings,
|
||||
)
|
||||
self._remember_payload(payload_key, payload)
|
||||
return payload
|
||||
|
||||
def _index(
|
||||
self,
|
||||
source_identity: tuple[object, ...],
|
||||
source: Path,
|
||||
metadata: Path | None,
|
||||
capture_clock: Path | None,
|
||||
capture_clock_origin: Path | None,
|
||||
) -> _PointColorIndex:
|
||||
with self._lock:
|
||||
cached = self._indexes.get(source_identity)
|
||||
if cached is not None:
|
||||
self._indexes.move_to_end(source_identity)
|
||||
return cached
|
||||
if metadata is None:
|
||||
raise RecordedPointColorError("native point-color index is unavailable")
|
||||
index = _build_index(
|
||||
source,
|
||||
metadata,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
source_identity=source_identity,
|
||||
)
|
||||
if index.byte_length > self._index_cache_bytes:
|
||||
return index
|
||||
with self._lock:
|
||||
existing = self._indexes.pop(source_identity, None)
|
||||
if existing is not None:
|
||||
self._index_bytes -= existing.byte_length
|
||||
self._indexes[source_identity] = index
|
||||
self._index_bytes += index.byte_length
|
||||
while self._indexes and self._index_bytes > self._index_cache_bytes:
|
||||
_, stale = self._indexes.popitem(last=False)
|
||||
self._index_bytes -= stale.byte_length
|
||||
return index
|
||||
|
||||
def _remember_payload(self, key: tuple[object, ...], payload: bytes) -> None:
|
||||
if len(payload) > self._payload_cache_bytes:
|
||||
return
|
||||
with self._lock:
|
||||
existing = self._payloads.pop(key, None)
|
||||
if existing is not None:
|
||||
self._payload_bytes -= len(existing)
|
||||
self._payloads[key] = payload
|
||||
self._payload_bytes += len(payload)
|
||||
while self._payloads and self._payload_bytes > self._payload_cache_bytes:
|
||||
_, stale = self._payloads.popitem(last=False)
|
||||
self._payload_bytes -= len(stale)
|
||||
|
||||
|
||||
def _build_index(
|
||||
source: Path,
|
||||
metadata: Path,
|
||||
capture_clock: Path | None,
|
||||
capture_clock_origin: Path | None,
|
||||
*,
|
||||
source_identity: tuple[object, ...],
|
||||
) -> _PointColorIndex:
|
||||
try:
|
||||
envelope = None if capture_clock is None else read_capture_clock_envelope(capture_clock)
|
||||
origin = (
|
||||
None
|
||||
if capture_clock_origin is None
|
||||
else read_capture_clock_origin(capture_clock_origin)
|
||||
)
|
||||
except CaptureFormatError as exc:
|
||||
raise RecordedPointColorError("native point-color clock is invalid") from exc
|
||||
if envelope is not None and origin is not None and (
|
||||
envelope.started_at_epoch_ns != origin.started_at_epoch_ns
|
||||
or envelope.started_monotonic_ns != origin.started_monotonic_ns
|
||||
):
|
||||
raise RecordedPointColorError("native point-color clocks do not match")
|
||||
session_origin_ns = (
|
||||
envelope.started_monotonic_ns
|
||||
if envelope is not None
|
||||
else origin.started_monotonic_ns
|
||||
if origin is not None
|
||||
else None
|
||||
)
|
||||
|
||||
frames: list[_PointColorFrame] = []
|
||||
total_bytes = 0
|
||||
point_frame_number = 0
|
||||
previous_sequence = 0
|
||||
previous_monotonic_ns: int | None = None
|
||||
try:
|
||||
source_size = source.stat().st_size
|
||||
with source.open("rb") as raw_stream, metadata.open("r", encoding="utf-8") as index_stream:
|
||||
for raw_line in index_stream:
|
||||
if len(raw_line.encode("utf-8")) > _MAX_METADATA_LINE_BYTES:
|
||||
raise RecordedPointColorError("native point-color metadata line is too large")
|
||||
record = json.loads(raw_line)
|
||||
sequence = record.get("sequence")
|
||||
monotonic_ns = record.get("received_monotonic_ns")
|
||||
capture_time_ns = record.get("received_at_epoch_ns")
|
||||
if (
|
||||
record.get("record_type") != "message"
|
||||
or not isinstance(sequence, int)
|
||||
or sequence <= previous_sequence
|
||||
or not isinstance(monotonic_ns, int)
|
||||
or monotonic_ns < 0
|
||||
or not isinstance(capture_time_ns, int)
|
||||
or capture_time_ns < 0
|
||||
):
|
||||
raise RecordedPointColorError("native point-color metadata is invalid")
|
||||
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
|
||||
raise RecordedPointColorError("native point-color timeline decreases")
|
||||
previous_sequence = sequence
|
||||
previous_monotonic_ns = monotonic_ns
|
||||
if session_origin_ns is None:
|
||||
session_origin_ns = monotonic_ns
|
||||
topic = record.get("topic")
|
||||
if topic not in _POINT_TOPICS:
|
||||
continue
|
||||
point_frame_number += 1
|
||||
if not _should_publish_recorded_point_frame(point_frame_number):
|
||||
continue
|
||||
payload_offset = record.get("raw_payload_offset")
|
||||
payload_bytes = record.get("payload_bytes")
|
||||
payload_sha256 = record.get("payload_sha256")
|
||||
if (
|
||||
not isinstance(payload_offset, int)
|
||||
or payload_offset < 8
|
||||
or not isinstance(payload_bytes, int)
|
||||
or payload_bytes < 0
|
||||
or payload_bytes > _MAX_PAYLOAD_BYTES
|
||||
or payload_offset + payload_bytes > source_size
|
||||
or not isinstance(payload_sha256, str)
|
||||
or len(payload_sha256) != 64
|
||||
):
|
||||
raise RecordedPointColorError("native point-color offset is invalid")
|
||||
raw_stream.seek(payload_offset)
|
||||
payload = raw_stream.read(payload_bytes)
|
||||
if (
|
||||
len(payload) != payload_bytes
|
||||
or hashlib.sha256(payload).hexdigest() != payload_sha256
|
||||
):
|
||||
raise RecordedPointColorError("native point-color payload is corrupt")
|
||||
message = StreamMessage(
|
||||
sequence=sequence,
|
||||
topic=topic,
|
||||
payload=payload,
|
||||
received_at_epoch_ns=capture_time_ns,
|
||||
received_monotonic_ns=monotonic_ns,
|
||||
source="k1mqtt",
|
||||
)
|
||||
try:
|
||||
decoded = normalize_k1_message(
|
||||
message,
|
||||
processing_started_monotonic_ns=monotonic_ns,
|
||||
)
|
||||
except NormalizationError as exc:
|
||||
raise RecordedPointColorError(
|
||||
"native point-color frame failed normalization"
|
||||
) from exc
|
||||
if not isinstance(decoded, DecodedPointCloudView):
|
||||
raise RecordedPointColorError("native point-color topic is not a point frame")
|
||||
positions = np.asarray(decoded.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||
intensities = (
|
||||
np.full(decoded.point_count, 255, dtype=np.uint8)
|
||||
if decoded.intensities is None
|
||||
else np.frombuffer(decoded.intensities, dtype=np.uint8)
|
||||
)
|
||||
rgb = (
|
||||
None
|
||||
if decoded.colors_rgb is None
|
||||
else np.frombuffer(decoded.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||
)
|
||||
positions, intensities, rgb = _recorded_view_points(
|
||||
positions,
|
||||
intensities,
|
||||
rgb,
|
||||
)
|
||||
frame = _PointColorFrame(
|
||||
sequence=sequence,
|
||||
session_time_ns=monotonic_ns - session_origin_ns,
|
||||
capture_time_ns=capture_time_ns,
|
||||
positions=positions.copy(),
|
||||
intensities=intensities.copy(),
|
||||
rgb=None if rgb is None else rgb.copy(),
|
||||
)
|
||||
frames.append(frame)
|
||||
total_bytes += frame.byte_length
|
||||
except (OSError, json.JSONDecodeError, UnicodeError) as exc:
|
||||
raise RecordedPointColorError("native point-color index could not be read") from exc
|
||||
if session_origin_ns is None or not frames:
|
||||
raise RecordedPointColorError("native point-color index contains no point frames")
|
||||
return _PointColorIndex(
|
||||
source_identity=source_identity,
|
||||
frames=tuple(frames),
|
||||
byte_length=total_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _render_color_overlay(
|
||||
frames: tuple[_PointColorFrame, ...],
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
settings: RerunSceneSettings,
|
||||
) -> bytes:
|
||||
recording = rr.RecordingStream(
|
||||
application_id,
|
||||
recording_id=recording_id,
|
||||
send_properties=False,
|
||||
)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
for frame in frames:
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(frame.session_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time(
|
||||
CAPTURE_TIMELINE,
|
||||
timestamp=np.datetime64(frame.capture_time_ns, "ns"),
|
||||
)
|
||||
recording.set_time("message_sequence", sequence=frame.sequence)
|
||||
recording.log(
|
||||
"/world/points",
|
||||
rr.Points3D.from_fields(
|
||||
colors=_point_colors(
|
||||
frame.positions,
|
||||
frame.intensities,
|
||||
frame.rgb,
|
||||
settings,
|
||||
)
|
||||
),
|
||||
)
|
||||
payload = stream.read(flush=True, flush_timeout_sec=30.0)
|
||||
except Exception as exc:
|
||||
raise RecordedPointColorError("recorded point-color RRD serialization failed") from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if not payload or not payload.startswith(b"RRF2"):
|
||||
raise RecordedPointColorError("recorded point-color RRD is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _artifact_path(command: ReplayCommand, artifact_id: str) -> Path | None:
|
||||
matches = tuple(
|
||||
artifact.path.expanduser().resolve()
|
||||
for artifact in command.artifacts
|
||||
if artifact.artifact_id == artifact_id
|
||||
)
|
||||
if not matches:
|
||||
return None
|
||||
if len(matches) != 1:
|
||||
raise RecordedPointColorError("recorded point-color artifact is ambiguous")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _source_identity(source: Path, *companions: Path | None) -> tuple[object, ...]:
|
||||
values: list[object] = []
|
||||
for path in (source, *companions):
|
||||
if path is None:
|
||||
values.extend((None, None, None))
|
||||
continue
|
||||
try:
|
||||
metadata = path.stat()
|
||||
except OSError as exc:
|
||||
raise RecordedPointColorError("recorded point-color source is unavailable") from exc
|
||||
if not os.path.isfile(path):
|
||||
raise RecordedPointColorError("recorded point-color source is not a file")
|
||||
values.extend((str(path), metadata.st_size, metadata.st_mtime_ns))
|
||||
return tuple(values)
|
||||
|
|
@ -11,9 +11,9 @@ import threading
|
|||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from .models import ObservationSessionCandidate
|
||||
from .models import ObservationSessionCandidate, ReplayCommand
|
||||
|
||||
RecordingProgressPulse = Callable[[], None]
|
||||
RecordingExportResult = Mapping[str, object]
|
||||
|
|
@ -38,6 +38,19 @@ class RecordingExporter(Protocol):
|
|||
) -> RecordingExportResult: ...
|
||||
|
||||
|
||||
class RecordedPointColorRenderer(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"],
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"],
|
||||
custom_color: str,
|
||||
) -> bytes: ...
|
||||
|
||||
|
||||
ObservationArchiveDiscovery = Callable[
|
||||
[Path],
|
||||
tuple[ObservationSessionCandidate, ...],
|
||||
|
|
@ -66,3 +79,4 @@ class ObservationRuntimeContribution:
|
|||
|
||||
archives: tuple[ObservationArchiveSource, ...]
|
||||
recording_exporter: RecordingExporter
|
||||
point_color_renderer: RecordedPointColorRenderer | None = None
|
||||
|
|
|
|||
|
|
@ -12,13 +12,15 @@ import rerun as rr
|
|||
import rerun_bindings as bindings
|
||||
from rerun import blueprint as rrb
|
||||
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings, _parse_hex_color
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
|
||||
APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
|
||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID = UUID("1480abcc-a0ae-4287-ab69-4b606d15d947")
|
||||
RECORDED_SPATIAL_FOLLOW_VIEW_ID = UUID("e96acd7f-1bb8-49e2-bff8-a661e5c6ceab")
|
||||
RECORDED_SPATIAL_FOLLOW_RESET_VIEW_ID = UUID("f409403d-84ac-4309-82aa-501fc69905f5")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_SPATIAL_RESET_ROOT_CONTAINER_ID = UUID("27d95ad7-1e72-46ca-b854-d02cd99b7610")
|
||||
RECORDED_PERCEPTION_ROOT_CONTAINER_ID = UUID("70ea9fd5-bbbb-4b23-8ae8-8098af92b997")
|
||||
|
|
@ -33,6 +35,8 @@ RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
|
|||
RECORDED_CAMERA_RESET_VIEW_ID = UUID("d0618e0c-c889-4222-a643-60a4a882935c")
|
||||
RECORDED_PERCEPTION_3D_VIEW_ID = UUID("0496bd2e-2b4d-4a4f-87b8-3ce4f9f7e114")
|
||||
RECORDED_PERCEPTION_3D_RESET_VIEW_ID = UUID("31f63ab8-ffdd-4751-ae40-e8e7b4462c8f")
|
||||
RECORDED_PERCEPTION_3D_FOLLOW_VIEW_ID = UUID("6bb43236-9507-4e8f-bc54-3a73b31c07b5")
|
||||
RECORDED_PERCEPTION_3D_FOLLOW_RESET_VIEW_ID = UUID("0e01e3c6-8b7b-47aa-b22b-e413301599eb")
|
||||
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
||||
RECORDED_UNIFIED_ROOT_CONTAINER_ID = UUID("e9934ef8-453f-432e-9136-2b0908190253")
|
||||
RECORDED_UNIFIED_RESET_ROOT_CONTAINER_ID = UUID("2781407d-9f4e-4405-86e8-bbe6065e83df")
|
||||
|
|
@ -155,6 +159,7 @@ def recorded_blueprint(
|
|||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
follow_trajectory: bool = False,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
|
|
@ -171,16 +176,10 @@ def recorded_blueprint(
|
|||
)
|
||||
point_visualizer = rr.Points3D.from_fields(
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None
|
||||
),
|
||||
).visualizer()
|
||||
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||
perception_point_visualizer = rr.Points3D.from_fields(
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)] if settings.palette == "custom" else None
|
||||
),
|
||||
).visualizer()
|
||||
perception_point_visualizer.id = RECORDED_PERCEPTION_POINTS_VISUALIZER_ID
|
||||
point_overrides: list[Any] = [
|
||||
|
|
@ -219,10 +218,21 @@ def recorded_blueprint(
|
|||
# Log an explicit empty range set so a previous accumulated blueprint
|
||||
# for this stable view id is cleared instead of surviving in Rerun.
|
||||
time_ranges=rrb.VisibleTimeRanges([]),
|
||||
eye_controls=(
|
||||
rrb.EyeControls3D(
|
||||
kind=rrb.Eye3DKind.Orbital,
|
||||
tracking_entity="/world/sensor_pose",
|
||||
)
|
||||
if follow_trajectory
|
||||
else None
|
||||
),
|
||||
)
|
||||
spatial_view.id = (
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID if view_reset_generation else RECORDED_SPATIAL_VIEW_ID
|
||||
)
|
||||
spatial_view.id = {
|
||||
(False, 0): RECORDED_SPATIAL_VIEW_ID,
|
||||
(False, 1): RECORDED_SPATIAL_RESET_VIEW_ID,
|
||||
(True, 0): RECORDED_SPATIAL_FOLLOW_VIEW_ID,
|
||||
(True, 1): RECORDED_SPATIAL_FOLLOW_RESET_VIEW_ID,
|
||||
}[(follow_trajectory, view_reset_generation)]
|
||||
camera_view = rrb.Spatial2DView(
|
||||
origin="/perception/camera",
|
||||
name="Оригинальное видео · слои AI",
|
||||
|
|
@ -269,12 +279,21 @@ def recorded_blueprint(
|
|||
# native cloud from /world/points.
|
||||
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
eye_controls=(
|
||||
rrb.EyeControls3D(
|
||||
kind=rrb.Eye3DKind.Orbital,
|
||||
tracking_entity="/world/sensor_pose",
|
||||
)
|
||||
if follow_trajectory
|
||||
else None
|
||||
),
|
||||
)
|
||||
perception_3d_view.id = (
|
||||
RECORDED_PERCEPTION_3D_RESET_VIEW_ID
|
||||
if view_reset_generation
|
||||
else RECORDED_PERCEPTION_3D_VIEW_ID
|
||||
)
|
||||
perception_3d_view.id = {
|
||||
(False, 0): RECORDED_PERCEPTION_3D_VIEW_ID,
|
||||
(False, 1): RECORDED_PERCEPTION_3D_RESET_VIEW_ID,
|
||||
(True, 0): RECORDED_PERCEPTION_3D_FOLLOW_VIEW_ID,
|
||||
(True, 1): RECORDED_PERCEPTION_3D_FOLLOW_RESET_VIEW_ID,
|
||||
}[(follow_trajectory, view_reset_generation)]
|
||||
metrics_view = rrb.TimeSeriesView(
|
||||
origin="/metrics/device",
|
||||
name="Маршрут и время",
|
||||
|
|
@ -372,6 +391,7 @@ def recorded_blueprint_rrd(
|
|||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
follow_trajectory: bool = False,
|
||||
) -> bytes:
|
||||
"""Serialize a bounded active blueprint update without recorded data."""
|
||||
|
||||
|
|
@ -384,6 +404,7 @@ def recorded_blueprint_rrd(
|
|||
show_detections_2d=show_detections_2d,
|
||||
show_segmentation=show_segmentation,
|
||||
show_cuboids_3d=show_cuboids_3d,
|
||||
follow_trajectory=follow_trajectory,
|
||||
)
|
||||
payload: bytes | None
|
||||
if blueprint_session_id is not None:
|
||||
|
|
|
|||
|
|
@ -491,6 +491,9 @@ def _point_colors(
|
|||
rgb: np.ndarray | None,
|
||||
settings: RerunSceneSettings,
|
||||
) -> np.ndarray:
|
||||
if settings.palette == "custom":
|
||||
color = _parse_hex_color(settings.custom_color)
|
||||
return np.tile(np.asarray(color, dtype=np.uint8), (positions.shape[0], 1))
|
||||
if settings.color_mode == "rgb" and rgb is not None:
|
||||
return rgb
|
||||
if settings.color_mode == "class":
|
||||
|
|
|
|||
|
|
@ -393,6 +393,7 @@ app.include_router(
|
|||
media_inspector=session_recorded_media_inspector,
|
||||
perception_overlay_provider=session_perception_overlay_store,
|
||||
perception_media_provider=session_perception_epoch_store,
|
||||
point_color_renderers=plugin_environment.point_color_renderers,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ from uuid import uuid4
|
|||
from fastapi import APIRouter
|
||||
from missioncore_plugin_sdk.v0alpha2 import RuntimeHandshakeRequest
|
||||
|
||||
from k1link.sessions.plugin_contract import ObservationArchiveSource, RecordingExporter
|
||||
from k1link.sessions.plugin_contract import (
|
||||
ObservationArchiveSource,
|
||||
RecordedPointColorRenderer,
|
||||
RecordingExporter,
|
||||
)
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, DevicePluginManifest
|
||||
from k1link.web.plugin_runtime import (
|
||||
DevicePluginDispatcher,
|
||||
|
|
@ -48,6 +52,15 @@ class InstalledDevicePluginEnvironment:
|
|||
if contribution.observation is not None
|
||||
}
|
||||
|
||||
@property
|
||||
def point_color_renderers(self) -> dict[str, RecordedPointColorRenderer]:
|
||||
return {
|
||||
contribution.runtime.descriptor.plugin_id: renderer
|
||||
for contribution in self._contributions
|
||||
if contribution.observation is not None
|
||||
if (renderer := contribution.observation.point_color_renderer) is not None
|
||||
}
|
||||
|
||||
@property
|
||||
def runtime_health(self) -> tuple[dict[str, Any], ...]:
|
||||
return tuple(
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from k1link.sessions import (
|
|||
SessionStore,
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
||||
from k1link.viewer.recorded import (
|
||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||
)
|
||||
|
|
@ -108,6 +109,7 @@ class RecordedBlueprintRequest(StrictApiModel):
|
|||
show_trajectory: StrictBool
|
||||
show_grid: StrictBool
|
||||
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
|
||||
|
|
@ -116,6 +118,7 @@ class RecordedBlueprintRequest(StrictApiModel):
|
|||
show_detections_2d: StrictBool = False
|
||||
show_segmentation: StrictBool = False
|
||||
show_cuboids_3d: StrictBool = False
|
||||
follow_trajectory: StrictBool = False
|
||||
|
||||
|
||||
class RecordedPerceptionRequest(StrictApiModel):
|
||||
|
|
@ -127,6 +130,18 @@ class RecordedPerceptionRequest(StrictApiModel):
|
|||
)
|
||||
|
||||
|
||||
class RecordedPointColorsRequest(StrictApiModel):
|
||||
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}$",
|
||||
)
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"]
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
||||
custom_color: str = Field(pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
class SceneSettingsDocument(StrictApiModel):
|
||||
projection: Literal["3d", "2d", "map"]
|
||||
point_size: float = Field(ge=0.1, le=32.0)
|
||||
|
|
@ -284,6 +299,7 @@ def build_session_router(
|
|||
media_inspector: RecordedMediaInspector | None = None,
|
||||
perception_overlay_provider: RecordedPerceptionOverlayProvider | None = None,
|
||||
perception_media_provider: RecordedPerceptionMediaProvider | None = None,
|
||||
point_color_renderers: Mapping[str, RecordedPointColorRenderer] | None = None,
|
||||
allow_synchronous_recording_fallback: bool = False,
|
||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||
) -> APIRouter:
|
||||
|
|
@ -796,6 +812,7 @@ def build_session_router(
|
|||
recorded_blueprint_rrd,
|
||||
RerunSceneSettings(
|
||||
point_size=request.point_size,
|
||||
color_mode=request.color_mode,
|
||||
palette=request.palette,
|
||||
custom_color=request.custom_color,
|
||||
accumulation_seconds=request.accumulation_seconds,
|
||||
|
|
@ -812,6 +829,7 @@ def build_session_router(
|
|||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
follow_trajectory=request.follow_trajectory,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
|
@ -887,6 +905,61 @@ def build_session_router(
|
|||
},
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/point-colors.rrd")
|
||||
async def get_observation_session_point_colors(
|
||||
session_id: str,
|
||||
request: RecordedPointColorsRequest,
|
||||
) -> Response:
|
||||
try:
|
||||
command = await run_in_threadpool(
|
||||
_prepare_replay,
|
||||
store,
|
||||
catalog_refresher,
|
||||
session_id,
|
||||
1.0,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
point_color_renderer = (point_color_renderers or {}).get(command.plugin_id)
|
||||
if point_color_renderer is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Сервис архивной окраски точек не настроен.",
|
||||
)
|
||||
payload = await run_in_threadpool(
|
||||
point_color_renderer,
|
||||
command,
|
||||
application_id=request.application_id,
|
||||
recording_id=request.recording_id,
|
||||
color_mode=request.color_mode,
|
||||
palette=request.palette,
|
||||
custom_color=request.custom_color,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректные параметры окраски точек.",
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Не удалось подготовить окраску облака точек.",
|
||||
) from exc
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="application/vnd.rerun.rrd",
|
||||
headers={
|
||||
"Cache-Control": "private, no-cache, no-transform",
|
||||
"Content-Length": str(len(payload)),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": 'inline; filename="point-colors.rrd"',
|
||||
},
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/v1/observation-sessions/{session_id}/perception-media/"
|
||||
"{result_id}/manifest"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.device_plugins.xgrids_k1.recorded_point_colors import (
|
||||
RecordedPointColorOverlayStore,
|
||||
)
|
||||
from k1link.sessions import ReplayArtifact, ReplayCommand
|
||||
|
||||
|
||||
def _point_payload(x: float, intensity: int) -> bytes:
|
||||
return struct.pack("<III", 16, 0, 0) + struct.pack(
|
||||
"<fffBBBB",
|
||||
x,
|
||||
-2.0,
|
||||
3.0,
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
intensity,
|
||||
)
|
||||
|
||||
|
||||
def _command(tmp_path: Path) -> ReplayCommand:
|
||||
source = tmp_path / "mqtt.raw.k1mqtt"
|
||||
metadata_path = tmp_path / "mqtt.metadata.jsonl"
|
||||
raw = bytearray(RAW_MAGIC)
|
||||
metadata: list[dict[str, object]] = []
|
||||
epoch_origin_ns = 1_784_124_315_000_000_000
|
||||
monotonic_origin_ns = 9_000_000_000
|
||||
topic = "RealtimePointcloud"
|
||||
for sequence in range(1, 7):
|
||||
payload = _point_payload(float(sequence), sequence * 20)
|
||||
topic_bytes = topic.encode("utf-8")
|
||||
frame_offset = len(raw)
|
||||
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
|
||||
raw.extend(topic_bytes)
|
||||
payload_offset = len(raw)
|
||||
raw.extend(payload)
|
||||
metadata.append(
|
||||
{
|
||||
"record_type": "message",
|
||||
"sequence": sequence,
|
||||
"received_at_epoch_ns": epoch_origin_ns + sequence * 100_000_000,
|
||||
"received_monotonic_ns": monotonic_origin_ns + sequence * 100_000_000,
|
||||
"topic": topic,
|
||||
"payload_bytes": len(payload),
|
||||
"payload_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"raw_frame_offset": frame_offset,
|
||||
"raw_payload_offset": payload_offset,
|
||||
"raw_frame_bytes": len(raw) - frame_offset,
|
||||
}
|
||||
)
|
||||
source.write_bytes(raw)
|
||||
metadata_path.write_text(
|
||||
"".join(json.dumps(item, separators=(",", ":")) + "\n" for item in metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
artifacts = (
|
||||
ReplayArtifact(
|
||||
artifact_id="raw-transport-primary",
|
||||
path=source,
|
||||
media_type="application/x-nodedc-k1mqtt",
|
||||
file_byte_length=source.stat().st_size,
|
||||
replay_byte_length=source.stat().st_size,
|
||||
expected_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
),
|
||||
ReplayArtifact(
|
||||
artifact_id="raw-transport-index",
|
||||
path=metadata_path,
|
||||
media_type="application/x-ndjson",
|
||||
file_byte_length=metadata_path.stat().st_size,
|
||||
replay_byte_length=metadata_path.stat().st_size,
|
||||
expected_sha256=hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
|
||||
),
|
||||
)
|
||||
return ReplayCommand(
|
||||
session_id="recorded-color-test",
|
||||
plugin_id="nodedc.device.xgrids-lixelkity-k1",
|
||||
allowed_root=tmp_path,
|
||||
session_root=tmp_path,
|
||||
primary_artifact_id="raw-transport-primary",
|
||||
artifacts=artifacts,
|
||||
timeline_origin_epoch_ns=epoch_origin_ns,
|
||||
timeline_origin_monotonic_ns=monotonic_origin_ns,
|
||||
speed=1.0,
|
||||
loop=False,
|
||||
)
|
||||
|
||||
|
||||
def test_recorded_color_overlay_logs_only_colors_at_operator_frame_cadence(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = RecordedPointColorOverlayStore()
|
||||
payload = store.render(
|
||||
_command(tmp_path),
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001",
|
||||
color_mode="height",
|
||||
palette="viridis",
|
||||
custom_color="#35d7c1",
|
||||
)
|
||||
output = tmp_path / "colors.rrd"
|
||||
output.write_bytes(payload)
|
||||
printed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"rerun_cli",
|
||||
"rrd",
|
||||
"print",
|
||||
"-vvv",
|
||||
"--entity",
|
||||
"/world/points",
|
||||
str(output),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
|
||||
assert payload.startswith(b"RRF2")
|
||||
assert printed.count("Points3D:colors") == 2
|
||||
assert "Points3D:positions" not in printed
|
||||
assert "session_time" in printed
|
||||
|
||||
|
||||
def test_recorded_color_overlay_reuses_index_and_identical_payload(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = RecordedPointColorOverlayStore()
|
||||
command = _command(tmp_path)
|
||||
first = store.render(
|
||||
command,
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001",
|
||||
color_mode="distance",
|
||||
palette="plasma",
|
||||
custom_color="#35d7c1",
|
||||
)
|
||||
second = store.render(
|
||||
command,
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001",
|
||||
color_mode="distance",
|
||||
palette="plasma",
|
||||
custom_color="#35d7c1",
|
||||
)
|
||||
|
||||
assert second is first
|
||||
|
|
@ -247,12 +247,23 @@ def test_palettes_are_deterministic_and_custom_color_is_exact() -> None:
|
|||
None,
|
||||
RerunSceneSettings(color_mode="class", custom_color="#102030"),
|
||||
)
|
||||
custom_over_rgb = _point_colors(
|
||||
positions,
|
||||
intensities,
|
||||
np.asarray([[255, 0, 0], [0, 255, 0]], dtype=np.uint8),
|
||||
RerunSceneSettings(
|
||||
color_mode="rgb",
|
||||
palette="custom",
|
||||
custom_color="#102030",
|
||||
),
|
||||
)
|
||||
|
||||
assert height.shape == (2, 3)
|
||||
assert height.dtype == np.uint8
|
||||
assert height[0].tolist() == [68, 1, 84]
|
||||
assert height[1].tolist() == [253, 231, 37]
|
||||
assert custom.tolist() == [[16, 32, 48], [16, 32, 48]]
|
||||
assert custom_over_rgb.tolist() == [[16, 32, 48], [16, 32, 48]]
|
||||
|
||||
|
||||
def test_runtime_reuses_one_bridge_across_sequential_sessions(tmp_path: Path) -> None:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ from k1link.device_plugins.xgrids_k1.rrd_export import (
|
|||
export_k1mqtt_to_rrd,
|
||||
recorded_blueprint_rrd,
|
||||
)
|
||||
from k1link.viewer.recorded import (
|
||||
RECORDED_SPATIAL_FOLLOW_VIEW_ID,
|
||||
)
|
||||
from k1link.viewer.recorded import (
|
||||
recorded_blueprint as viewer_recorded_blueprint,
|
||||
)
|
||||
|
|
@ -450,6 +453,40 @@ def test_viewer_blueprint_reset_is_bounded_and_recreates_render_views() -> None:
|
|||
assert perception_behavior.visible.as_arrow_array().to_pylist() == [False]
|
||||
|
||||
|
||||
def test_recorded_follow_mode_tracks_sensor_pose_with_an_independent_orbital_eye() -> None:
|
||||
normal = viewer_recorded_blueprint(
|
||||
RerunSceneSettings(),
|
||||
include_initial_playback_state=False,
|
||||
)
|
||||
followed = viewer_recorded_blueprint(
|
||||
RerunSceneSettings(),
|
||||
include_initial_playback_state=False,
|
||||
follow_trajectory=True,
|
||||
)
|
||||
followed_again = viewer_recorded_blueprint(
|
||||
RerunSceneSettings(show_grid=False),
|
||||
include_initial_playback_state=False,
|
||||
follow_trajectory=True,
|
||||
)
|
||||
|
||||
normal_view = normal.root_container.contents[0]
|
||||
followed_view = followed.root_container.contents[0]
|
||||
followed_eye = followed_view.properties["EyeControls3D"]
|
||||
followed_components = {
|
||||
str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
|
||||
for batch in followed_eye.as_component_batches()
|
||||
}
|
||||
|
||||
assert "EyeControls3D" not in normal_view.properties
|
||||
assert followed_view.id == RECORDED_SPATIAL_FOLLOW_VIEW_ID
|
||||
assert followed_again.root_container.contents[0].id == RECORDED_SPATIAL_FOLLOW_VIEW_ID
|
||||
assert followed_view.id != normal_view.id
|
||||
assert followed_components == {
|
||||
"EyeControls3D:kind": [2],
|
||||
"EyeControls3D:tracking_entity": ["/world/sensor_pose"],
|
||||
}
|
||||
|
||||
|
||||
def test_recorded_blueprint_layer_updates_preserve_store_until_explicit_reset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from k1link.web.session_api import (
|
|||
LayoutPutRequest,
|
||||
RecordedBlueprintRequest,
|
||||
RecordedPerceptionRequest,
|
||||
RecordedPointColorsRequest,
|
||||
ReplayRequest,
|
||||
build_session_router,
|
||||
)
|
||||
|
|
@ -1083,6 +1084,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
|||
show_trajectory=True,
|
||||
show_grid=False,
|
||||
point_size=6.25,
|
||||
color_mode="height",
|
||||
palette="custom",
|
||||
custom_color="#112233",
|
||||
active_view="perception",
|
||||
|
|
@ -1091,6 +1093,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
|||
show_detections_2d=True,
|
||||
show_segmentation=True,
|
||||
show_cuboids_3d=True,
|
||||
follow_trajectory=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -1104,6 +1107,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
|||
assert len(observed_settings) == 1
|
||||
assert observed_settings[0].show_points is False
|
||||
assert observed_settings[0].show_trajectory is True
|
||||
assert observed_settings[0].color_mode == "height"
|
||||
assert observed_kwargs[0]["blueprint_session_id"] == "a" * 32
|
||||
assert observed_kwargs[0]["active_view"] == "perception"
|
||||
assert observed_kwargs[0]["view_reset_generation"] == 1
|
||||
|
|
@ -1111,6 +1115,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
|||
assert observed_kwargs[0]["show_detections_2d"] is True
|
||||
assert observed_kwargs[0]["show_segmentation"] is True
|
||||
assert observed_kwargs[0]["show_cuboids_3d"] is True
|
||||
assert observed_kwargs[0]["follow_trajectory"] is True
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
RecordedBlueprintRequest.model_validate(
|
||||
|
|
@ -1201,6 +1206,96 @@ def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
|
|||
assert empty.status_code == 204
|
||||
|
||||
|
||||
def test_recorded_point_color_endpoint_is_strict_and_forwards_confined_command(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
calls: list[tuple[str, str, str, str, str, str]] = []
|
||||
|
||||
class Provider:
|
||||
def __call__(
|
||||
self,
|
||||
command: ReplayCommand,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
color_mode: str,
|
||||
palette: str,
|
||||
custom_color: str,
|
||||
) -> bytes:
|
||||
calls.append(
|
||||
(
|
||||
command.session_id,
|
||||
application_id,
|
||||
recording_id,
|
||||
color_mode,
|
||||
palette,
|
||||
custom_color,
|
||||
)
|
||||
)
|
||||
return b"RRF2colors"
|
||||
|
||||
router = build_session_router(
|
||||
store,
|
||||
point_color_renderers={"nodedc.device.xgrids-lixelkity-k1": Provider()},
|
||||
)
|
||||
color_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/point-colors.rrd",
|
||||
"POST",
|
||||
)
|
||||
response = asyncio.run(
|
||||
color_route(
|
||||
session_id=session.name,
|
||||
request=RecordedPointColorsRequest(
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001",
|
||||
color_mode="distance",
|
||||
palette="viridis",
|
||||
custom_color="#112233",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert response.body == b"RRF2colors"
|
||||
assert response.media_type == "application/vnd.rerun.rrd"
|
||||
assert response.headers["content-length"] == str(len(response.body))
|
||||
assert len(calls) == 1
|
||||
assert calls[0] == (
|
||||
session.name,
|
||||
"nodedc_mission_core_recorded",
|
||||
"recording-001",
|
||||
"distance",
|
||||
"viridis",
|
||||
"#112233",
|
||||
)
|
||||
|
||||
unavailable_router = build_session_router(store)
|
||||
unavailable_route = endpoint(
|
||||
unavailable_router,
|
||||
"/api/v1/observation-sessions/{session_id}/point-colors.rrd",
|
||||
"POST",
|
||||
)
|
||||
with pytest.raises(HTTPException) as unavailable:
|
||||
asyncio.run(
|
||||
unavailable_route(
|
||||
session_id=session.name,
|
||||
request=RecordedPointColorsRequest(
|
||||
application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001",
|
||||
color_mode="intensity",
|
||||
palette="turbo",
|
||||
custom_color="#35d7c1",
|
||||
),
|
||||
)
|
||||
)
|
||||
assert unavailable.value.status_code == 409
|
||||
|
||||
|
||||
def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Reference in New Issue