From ecd95a22f08888d262125414f3fb0a4d9826f9da Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 23 Jul 2026 13:33:08 +0300 Subject: [PATCH] feat(viewer): add recorded point colors and trajectory follow --- apps/control-station/src/App.tsx | 21 +- .../src/components/RerunViewport.tsx | 202 +++++++- .../src/workspaces/Workspaces.tsx | 53 +++ .../test/observationSources.test.mjs | 79 ++++ .../device_plugins/xgrids_k1/observation.py | 5 + .../xgrids_k1/recorded_point_colors.py | 434 ++++++++++++++++++ src/k1link/sessions/plugin_contract.py | 18 +- src/k1link/viewer/recorded.py | 51 +- src/k1link/viewer/rerun_bridge.py | 3 + src/k1link/web/app.py | 1 + src/k1link/web/device_plugin_composition.py | 15 +- src/k1link/web/session_api.py | 73 +++ tests/test_recorded_point_colors.py | 156 +++++++ tests/test_rerun_bridge.py | 11 + tests/test_rrd_export.py | 37 ++ tests/test_session_api.py | 95 ++++ 16 files changed, 1218 insertions(+), 36 deletions(-) create mode 100644 src/k1link/device_plugins/xgrids_k1/recorded_point_colors.py create mode 100644 tests/test_recorded_point_colors.py diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 6b5efa8..a8d1e8d 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -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 })} /> @@ -965,12 +952,12 @@ export default function App() { variant="split" label="Палитра" value={displayDraft.palette} - options={displayPaletteOptions} + options={paletteOptions} onChange={(palette) => stageDisplayPatch({ palette })} /> - {displayDraft.palette === "custom" ? ( + {displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
- В архиве градиентные цвета уже записаны в RRD. Без переэкспорта - можно вернуть цвет записи или назначить один свой цвет. + Первый новый режим читает индекс архивных точек. Следующие палитры + переключаются из подготовленного цветового кэша без переэкспорта геометрии.

) : null}
diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index 4ef957d..78a7f38 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -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, +): 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, + identity: RecordedRerunIdentity, + { + origin, + signal, + fetcher = globalThis.fetch, + }: { + origin: string; + signal?: AbortSignal; + fetcher?: typeof globalThis.fetch; + }, +): Promise { + 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(null); const [status, setStatus] = useState(sourceUrl ? "loading" : "idle"); @@ -696,6 +801,7 @@ export function RerunViewport({ const blueprintChannelRef = useRef(null); const perceptionChannelRef = useRef(null); const loadedPerceptionChannelRef = useRef(null); + const appliedPointColorKeyRef = useRef(null); const recordedIdentityRef = useRef(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, diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 90fc358..51800cf 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -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(null); const [playbackController, setPlaybackController] = useState(null); const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0); + const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false); const [perceptionLoad, setPerceptionLoad] = useState({ phase: "idle", receivedBytes: 0, @@ -316,6 +318,13 @@ function SpatialWorkspace({ message: "", }); const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0); + const [pointColorLoad, setPointColorLoad] = useState({ + 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 ) : null} + {recordedSource && pointColorLoad.phase === "loading" ? ( +
+
+ ) : null} + {recordedSource && pointColorLoad.phase === "error" ? ( +
+ {pointColorLoad.message} +
+ ) : null} + {recordedSource && presentedViewerStatus === "ready" ? ( + + ) : null} {recordedSource && presentedViewerStatus === "ready" ? (