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" ? (
+
+
+ {pointColorLoad.message}
+
+ ) : null}
+ {recordedSource && pointColorLoad.phase === "error" ? (
+
+ {pointColorLoad.message}
+
+ ) : null}
+ {recordedSource && presentedViewerStatus === "ready" ? (
+ }
+ aria-pressed={followRecordedTrajectory}
+ title="Привязать orbital-пивот к текущему положению рига"
+ onClick={() => setFollowRecordedTrajectory((current) => !current)}
+ >
+ Следовать
+
+ ) : null}
{recordedSource && presentedViewerStatus === "ready" ? (
{
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",
diff --git a/src/k1link/device_plugins/xgrids_k1/observation.py b/src/k1link/device_plugins/xgrids_k1/observation.py
index e9dd3e8..4d80a3b 100644
--- a/src/k1link/device_plugins/xgrids_k1/observation.py
+++ b/src/k1link/device_plugins/xgrids_k1/observation.py
@@ -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,
)
diff --git a/src/k1link/device_plugins/xgrids_k1/recorded_point_colors.py b/src/k1link/device_plugins/xgrids_k1/recorded_point_colors.py
new file mode 100644
index 0000000..ab3af02
--- /dev/null
+++ b/src/k1link/device_plugins/xgrids_k1/recorded_point_colors.py
@@ -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)
diff --git a/src/k1link/sessions/plugin_contract.py b/src/k1link/sessions/plugin_contract.py
index a0f961d..6db8f6a 100644
--- a/src/k1link/sessions/plugin_contract.py
+++ b/src/k1link/sessions/plugin_contract.py
@@ -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
diff --git a/src/k1link/viewer/recorded.py b/src/k1link/viewer/recorded.py
index ea5a320..f7be466 100644
--- a/src/k1link/viewer/recorded.py
+++ b/src/k1link/viewer/recorded.py
@@ -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:
diff --git a/src/k1link/viewer/rerun_bridge.py b/src/k1link/viewer/rerun_bridge.py
index 8f86040..a3c0bc8 100644
--- a/src/k1link/viewer/rerun_bridge.py
+++ b/src/k1link/viewer/rerun_bridge.py
@@ -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":
diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py
index 7a8f58b..ac121e4 100644
--- a/src/k1link/web/app.py
+++ b/src/k1link/web/app.py
@@ -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,
)
)
diff --git a/src/k1link/web/device_plugin_composition.py b/src/k1link/web/device_plugin_composition.py
index 2644af0..5b98eb9 100644
--- a/src/k1link/web/device_plugin_composition.py
+++ b/src/k1link/web/device_plugin_composition.py
@@ -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(
diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py
index c35bc55..9131129 100644
--- a/src/k1link/web/session_api.py
+++ b/src/k1link/web/session_api.py
@@ -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"
diff --git a/tests/test_recorded_point_colors.py b/tests/test_recorded_point_colors.py
new file mode 100644
index 0000000..83d41c0
--- /dev/null
+++ b/tests/test_recorded_point_colors.py
@@ -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(" 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
diff --git a/tests/test_rerun_bridge.py b/tests/test_rerun_bridge.py
index 4d02bba..7627efa 100644
--- a/tests/test_rerun_bridge.py
+++ b/tests/test_rerun_bridge.py
@@ -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:
diff --git a/tests/test_rrd_export.py b/tests/test_rrd_export.py
index 4cc1886..c0ef8d4 100644
--- a/tests/test_rrd_export.py
+++ b/tests/test_rrd_export.py
@@ -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:
diff --git a/tests/test_session_api.py b/tests/test_session_api.py
index a7c902f..68de392 100644
--- a/tests/test_session_api.py
+++ b/tests/test_session_api.py
@@ -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: