feat(viewer): add recorded point colors and trajectory follow

This commit is contained in:
DCCONSTRUCTIONS 2026-07-23 13:33:08 +03:00
parent 9f712bf353
commit ecd95a22f0
16 changed files with 1218 additions and 36 deletions

View File

@ -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>

View File

@ -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");
@ -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,

View File

@ -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}

View File

@ -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",

View File

@ -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,
)

View File

@ -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)

View File

@ -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

View File

@ -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",
)
spatial_view.id = (
RECORDED_SPATIAL_RESET_VIEW_ID if view_reset_generation else RECORDED_SPATIAL_VIEW_ID
if follow_trajectory
else None
),
)
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",
)
perception_3d_view.id = (
RECORDED_PERCEPTION_3D_RESET_VIEW_ID
if view_reset_generation
else RECORDED_PERCEPTION_3D_VIEW_ID
if follow_trajectory
else None
),
)
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:

View File

@ -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":

View File

@ -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,
)
)

View File

@ -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(

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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: