feat(perception): project recorded results into Rerun
This commit is contained in:
parent
31fc4f6567
commit
2b53168149
|
|
@ -4,6 +4,7 @@ import type { SceneSettings } from "../sceneSettings";
|
||||||
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
||||||
|
|
||||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
export type RecordedRerunView = "spatial" | "perception" | "metrics";
|
||||||
|
|
||||||
export interface RerunSelection {
|
export interface RerunSelection {
|
||||||
entityPath: string;
|
entityPath: string;
|
||||||
|
|
@ -55,6 +56,8 @@ export interface RerunViewportProps {
|
||||||
| "palette"
|
| "palette"
|
||||||
| "customColor"
|
| "customColor"
|
||||||
>;
|
>;
|
||||||
|
recordedView?: RecordedRerunView;
|
||||||
|
onPerceptionAvailabilityChange?: (available: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordedRrdArtifactDescriptor {
|
export interface RecordedRrdArtifactDescriptor {
|
||||||
|
|
@ -80,7 +83,9 @@ interface RecordedRerunIdentity {
|
||||||
|
|
||||||
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
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_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 MAX_BLUEPRINT_BYTES = 1_048_576;
|
const MAX_BLUEPRINT_BYTES = 1_048_576;
|
||||||
|
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
|
||||||
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
||||||
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
|
const RECORDED_OPEN_MIN_TIMEOUT_MS = 120_000;
|
||||||
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
|
const RECORDED_OPEN_MAX_TIMEOUT_MS = 1_800_000;
|
||||||
|
|
@ -405,6 +410,17 @@ export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string):
|
||||||
return endpoint.origin === base.origin ? endpoint.href : null;
|
return endpoint.origin === base.origin ? endpoint.href : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveRecordedPerceptionUrl(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$/, "/perception.rrd"),
|
||||||
|
`${base.origin}/`,
|
||||||
|
);
|
||||||
|
return endpoint.origin === base.origin ? endpoint.href : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchRecordedBlueprintRrd(
|
export async function fetchRecordedBlueprintRrd(
|
||||||
endpointUrl: string,
|
endpointUrl: string,
|
||||||
settings: Pick<
|
settings: Pick<
|
||||||
|
|
@ -421,10 +437,12 @@ export async function fetchRecordedBlueprintRrd(
|
||||||
{
|
{
|
||||||
origin,
|
origin,
|
||||||
signal,
|
signal,
|
||||||
|
activeView = "spatial",
|
||||||
fetcher = globalThis.fetch,
|
fetcher = globalThis.fetch,
|
||||||
}: {
|
}: {
|
||||||
origin: string;
|
origin: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
activeView?: RecordedRerunView;
|
||||||
fetcher?: typeof globalThis.fetch;
|
fetcher?: typeof globalThis.fetch;
|
||||||
},
|
},
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
|
|
@ -443,6 +461,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||||
settings.pointSize > 32 ||
|
settings.pointSize > 32 ||
|
||||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||||
|
!["spatial", "perception", "metrics"].includes(activeView) ||
|
||||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
||||||
) {
|
) {
|
||||||
|
|
@ -465,6 +484,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||||
point_size: settings.pointSize,
|
point_size: settings.pointSize,
|
||||||
palette: settings.palette,
|
palette: settings.palette,
|
||||||
custom_color: settings.customColor,
|
custom_color: settings.customColor,
|
||||||
|
active_view: activeView,
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
@ -491,6 +511,69 @@ export async function fetchRecordedBlueprintRrd(
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchRecordedPerceptionRrd(
|
||||||
|
endpointUrl: string,
|
||||||
|
identity: RecordedRerunIdentity,
|
||||||
|
{
|
||||||
|
origin,
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
}: {
|
||||||
|
origin: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: typeof globalThis.fetch;
|
||||||
|
},
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
const base = new URL(origin);
|
||||||
|
const endpoint = new URL(endpointUrl, base.origin);
|
||||||
|
if (
|
||||||
|
endpoint.origin !== base.origin ||
|
||||||
|
endpoint.search ||
|
||||||
|
endpoint.hash ||
|
||||||
|
!RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||||
|
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||||
|
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
||||||
|
) {
|
||||||
|
throw new Error("Unsafe recorded perception 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,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (response.status === 204) return null;
|
||||||
|
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.isSafeInteger(declaredLength) ||
|
||||||
|
declaredLength < 4 ||
|
||||||
|
declaredLength > MAX_PERCEPTION_BYTES
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid recorded perception response");
|
||||||
|
}
|
||||||
|
const payload = new Uint8Array(await response.arrayBuffer());
|
||||||
|
if (
|
||||||
|
payload.byteLength !== declaredLength ||
|
||||||
|
payload[0] !== 0x52 ||
|
||||||
|
payload[1] !== 0x52 ||
|
||||||
|
payload[2] !== 0x46 ||
|
||||||
|
payload[3] !== 0x32
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid recorded perception RRD");
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
export function RerunViewport({
|
export function RerunViewport({
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
recordedArtifact = null,
|
recordedArtifact = null,
|
||||||
|
|
@ -504,19 +587,26 @@ export function RerunViewport({
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
onPlaybackControllerChange,
|
onPlaybackControllerChange,
|
||||||
sceneSettings,
|
sceneSettings,
|
||||||
|
recordedView = "spatial",
|
||||||
|
onPerceptionAvailabilityChange,
|
||||||
}: RerunViewportProps) {
|
}: RerunViewportProps) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
const [status, setStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||||
const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null);
|
const [recordingBufferProgress, setRecordingBufferProgress] = useState<number | null>(null);
|
||||||
const [retryNonce, setRetryNonce] = useState(0);
|
const [retryNonce, setRetryNonce] = useState(0);
|
||||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||||
|
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||||
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
||||||
const presentationGateRef = useRef(presentationGate);
|
const presentationGateRef = useRef(presentationGate);
|
||||||
presentationGateRef.current = presentationGate;
|
presentationGateRef.current = presentationGate;
|
||||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||||
|
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||||
const recordedBlueprintUrl = sourceUrl
|
const recordedBlueprintUrl = sourceUrl
|
||||||
? resolveRecordedBlueprintUrl(sourceUrl, window.location.origin)
|
? resolveRecordedBlueprintUrl(sourceUrl, window.location.origin)
|
||||||
: null;
|
: null;
|
||||||
|
const recordedPerceptionUrl = sourceUrl
|
||||||
|
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||||
|
: null;
|
||||||
const presentationStatus = rerunPresentationStatus(
|
const presentationStatus = rerunPresentationStatus(
|
||||||
status,
|
status,
|
||||||
presentationGate,
|
presentationGate,
|
||||||
|
|
@ -580,6 +670,7 @@ export function RerunViewport({
|
||||||
let playbackState: RerunPlaybackState | null = null;
|
let playbackState: RerunPlaybackState | null = null;
|
||||||
const recordedAutoplay = createRecordedAutoplayGate();
|
const recordedAutoplay = createRecordedAutoplayGate();
|
||||||
let blueprintChannel: RerunBlueprintChannel | null = null;
|
let blueprintChannel: RerunBlueprintChannel | null = null;
|
||||||
|
let perceptionChannel: RerunBlueprintChannel | null = null;
|
||||||
const unsubscribers: Array<() => void> = [];
|
const unsubscribers: Array<() => void> = [];
|
||||||
const unsubscribeAll = () => {
|
const unsubscribeAll = () => {
|
||||||
while (unsubscribers.length > 0) {
|
while (unsubscribers.length > 0) {
|
||||||
|
|
@ -665,12 +756,20 @@ export function RerunViewport({
|
||||||
if (blueprintChannelRef.current === blueprintChannel) {
|
if (blueprintChannelRef.current === blueprintChannel) {
|
||||||
blueprintChannelRef.current = null;
|
blueprintChannelRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (perceptionChannelRef.current === perceptionChannel) {
|
||||||
|
perceptionChannelRef.current = null;
|
||||||
|
}
|
||||||
recordedIdentityRef.current = null;
|
recordedIdentityRef.current = null;
|
||||||
try {
|
try {
|
||||||
blueprintChannel?.channel.close();
|
blueprintChannel?.channel.close();
|
||||||
} catch {
|
} catch {
|
||||||
// The viewer may already have closed all auxiliary channels.
|
// The viewer may already have closed all auxiliary channels.
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
perceptionChannel?.channel.close();
|
||||||
|
} catch {
|
||||||
|
// The viewer may already have closed all auxiliary channels.
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (viewer.ready) viewer.close(resolvedSource);
|
if (viewer.ready) viewer.close(resolvedSource);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -700,7 +799,11 @@ export function RerunViewport({
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
viewer.on("recording_open", (event) => {
|
viewer.on("recording_open", (event) => {
|
||||||
if (disposed || recordingOpened) return;
|
if (
|
||||||
|
disposed ||
|
||||||
|
recordingOpened ||
|
||||||
|
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
|
||||||
|
) return;
|
||||||
recordingOpened = true;
|
recordingOpened = true;
|
||||||
if (
|
if (
|
||||||
recordedBlueprintUrl &&
|
recordedBlueprintUrl &&
|
||||||
|
|
@ -717,6 +820,7 @@ export function RerunViewport({
|
||||||
recordingId: event.recording_id,
|
recordingId: event.recording_id,
|
||||||
};
|
};
|
||||||
setBlueprintChannelRevision((revision) => revision + 1);
|
setBlueprintChannelRevision((revision) => revision + 1);
|
||||||
|
setPerceptionChannelRevision((revision) => revision + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isRecordedSource) clearLiveRecordingOpenTimer();
|
if (!isRecordedSource) clearLiveRecordingOpenTimer();
|
||||||
|
|
@ -961,6 +1065,12 @@ export function RerunViewport({
|
||||||
blueprintChannelRef.current = blueprintChannel;
|
blueprintChannelRef.current = blueprintChannel;
|
||||||
setBlueprintChannelRevision((revision) => revision + 1);
|
setBlueprintChannelRevision((revision) => revision + 1);
|
||||||
}
|
}
|
||||||
|
if (recordedPerceptionUrl) {
|
||||||
|
const channel = viewer.open_channel("missioncore/recorded-perception");
|
||||||
|
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
|
||||||
|
perceptionChannelRef.current = perceptionChannel;
|
||||||
|
setPerceptionChannelRevision((revision) => revision + 1);
|
||||||
|
}
|
||||||
|
|
||||||
if (!recordingOpened && !isRecordedSource) {
|
if (!recordingOpened && !isRecordedSource) {
|
||||||
recordingOpenTimer = window.setTimeout(() => {
|
recordingOpenTimer = window.setTimeout(() => {
|
||||||
|
|
@ -1012,6 +1122,50 @@ export function RerunViewport({
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPerceptionAvailabilityChange?.(false);
|
||||||
|
}, [onPerceptionAvailabilityChange, recordedPerceptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!recordedPerceptionUrl) return;
|
||||||
|
const active = perceptionChannelRef.current;
|
||||||
|
const identity = recordedIdentityRef.current;
|
||||||
|
if (
|
||||||
|
!active ||
|
||||||
|
!identity ||
|
||||||
|
active.endpointUrl !== recordedPerceptionUrl ||
|
||||||
|
!active.channel.ready
|
||||||
|
) return;
|
||||||
|
const abort = new AbortController();
|
||||||
|
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
||||||
|
origin: window.location.origin,
|
||||||
|
signal: abort.signal,
|
||||||
|
}).then((payload) => {
|
||||||
|
if (payload === null) {
|
||||||
|
onPerceptionAvailabilityChange?.(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
abort.signal.aborted ||
|
||||||
|
perceptionChannelRef.current !== active ||
|
||||||
|
recordedIdentityRef.current !== identity ||
|
||||||
|
!active.channel.ready
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
active.channel.send_rrd(payload);
|
||||||
|
onPerceptionAvailabilityChange?.(true);
|
||||||
|
}).catch(() => {
|
||||||
|
onPerceptionAvailabilityChange?.(false);
|
||||||
|
// The base recording remains available when no admitted perception layer exists.
|
||||||
|
});
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [
|
||||||
|
onPerceptionAvailabilityChange,
|
||||||
|
perceptionChannelRevision,
|
||||||
|
recordedPerceptionUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recordedBlueprintUrl || !sceneSettings) return;
|
if (!recordedBlueprintUrl || !sceneSettings) return;
|
||||||
const active = blueprintChannelRef.current;
|
const active = blueprintChannelRef.current;
|
||||||
|
|
@ -1026,6 +1180,7 @@ export function RerunViewport({
|
||||||
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
|
activeView: recordedView,
|
||||||
}).then((payload) => {
|
}).then((payload) => {
|
||||||
if (
|
if (
|
||||||
abort.signal.aborted ||
|
abort.signal.aborted ||
|
||||||
|
|
@ -1044,6 +1199,7 @@ export function RerunViewport({
|
||||||
}, [
|
}, [
|
||||||
blueprintChannelRevision,
|
blueprintChannelRevision,
|
||||||
recordedBlueprintUrl,
|
recordedBlueprintUrl,
|
||||||
|
recordedView,
|
||||||
sceneSettings?.accumulationSeconds,
|
sceneSettings?.accumulationSeconds,
|
||||||
sceneSettings?.customColor,
|
sceneSettings?.customColor,
|
||||||
sceneSettings?.palette,
|
sceneSettings?.palette,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
type RerunPlaybackState,
|
type RerunPlaybackState,
|
||||||
type RerunSelection,
|
type RerunSelection,
|
||||||
type RerunViewportStatus,
|
type RerunViewportStatus,
|
||||||
|
type RecordedRerunView,
|
||||||
} from "../components/RerunViewport";
|
} from "../components/RerunViewport";
|
||||||
import {
|
import {
|
||||||
capabilityStatusLabel,
|
capabilityStatusLabel,
|
||||||
|
|
@ -294,6 +295,8 @@ function SpatialWorkspace({
|
||||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||||
|
const [recordedRerunView, setRecordedRerunView] = useState<RecordedRerunView>("spatial");
|
||||||
|
const [perceptionAvailable, setPerceptionAvailable] = useState(false);
|
||||||
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||||
? recordedSessionAdmission?.phase ?? "loading"
|
? recordedSessionAdmission?.phase ?? "loading"
|
||||||
|
|
@ -369,6 +372,15 @@ function SpatialWorkspace({
|
||||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const onPerceptionAvailabilityChange = useCallback((available: boolean) => {
|
||||||
|
setPerceptionAvailable(available);
|
||||||
|
if (!available) setRecordedRerunView("spatial");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPerceptionAvailable(false);
|
||||||
|
setRecordedRerunView("spatial");
|
||||||
|
}, [sourceUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||||
|
|
@ -377,6 +389,8 @@ function SpatialWorkspace({
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
setPlaybackState(null);
|
setPlaybackState(null);
|
||||||
setPlaybackController(null);
|
setPlaybackController(null);
|
||||||
|
setPerceptionAvailable(false);
|
||||||
|
setRecordedRerunView("spatial");
|
||||||
}, [pointCloudVisible, sourceUrl]);
|
}, [pointCloudVisible, sourceUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -419,6 +433,17 @@ function SpatialWorkspace({
|
||||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="spatial-toolbar__actions">
|
<div className="spatial-toolbar__actions">
|
||||||
|
{recordedSource && perceptionAvailable ? (
|
||||||
|
<Button
|
||||||
|
size="compact"
|
||||||
|
variant={recordedRerunView === "perception" ? "primary" : "secondary"}
|
||||||
|
icon={<Icon name={recordedRerunView === "perception" ? "globe" : "image"} />}
|
||||||
|
onClick={() => setRecordedRerunView((current) =>
|
||||||
|
current === "perception" ? "spatial" : "perception")}
|
||||||
|
>
|
||||||
|
{recordedRerunView === "perception" ? "Облако точек" : "Распознавание"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||||
Движок
|
Движок
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -451,6 +476,8 @@ function SpatialWorkspace({
|
||||||
? state?.observationTimeline?.range?.endSeconds
|
? state?.observationTimeline?.range?.endSeconds
|
||||||
: undefined}
|
: undefined}
|
||||||
sceneSettings={sceneSettings}
|
sceneSettings={sceneSettings}
|
||||||
|
recordedView={recordedRerunView}
|
||||||
|
onPerceptionAvailabilityChange={onPerceptionAvailabilityChange}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
onPlaybackChange={onPlaybackChange}
|
onPlaybackChange={onPlaybackChange}
|
||||||
|
|
@ -630,7 +657,7 @@ function SpatialWorkspace({
|
||||||
<span><i data-state="ready" />Траектория</span>
|
<span><i data-state="ready" />Траектория</span>
|
||||||
<span><i data-state="ready" />Преобразования</span>
|
<span><i data-state="ready" />Преобразования</span>
|
||||||
<span><i data-state="contract" />Камеры в 3D</span>
|
<span><i data-state="contract" />Камеры в 3D</span>
|
||||||
<span><i data-state="contract" />Объекты / маски</span>
|
<span><i data-state={perceptionAvailable ? "ready" : "contract"} />Объекты / рамки</span>
|
||||||
<span><i data-state="contract" />Компоновка</span>
|
<span><i data-state="contract" />Компоновка</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ let formatAccumulationDuration;
|
||||||
let resolveRerunSourceUrl;
|
let resolveRerunSourceUrl;
|
||||||
let resolveRecordedBlueprintUrl;
|
let resolveRecordedBlueprintUrl;
|
||||||
let fetchRecordedBlueprintRrd;
|
let fetchRecordedBlueprintRrd;
|
||||||
|
let resolveRecordedPerceptionUrl;
|
||||||
|
let fetchRecordedPerceptionRrd;
|
||||||
let isRecordedPlaybackFullyBuffered;
|
let isRecordedPlaybackFullyBuffered;
|
||||||
let recordedObservationSources;
|
let recordedObservationSources;
|
||||||
let selectRecordedMediaEpoch;
|
let selectRecordedMediaEpoch;
|
||||||
|
|
@ -62,6 +64,8 @@ before(async () => {
|
||||||
resolveRerunSourceUrl,
|
resolveRerunSourceUrl,
|
||||||
resolveRecordedBlueprintUrl,
|
resolveRecordedBlueprintUrl,
|
||||||
fetchRecordedBlueprintRrd,
|
fetchRecordedBlueprintRrd,
|
||||||
|
resolveRecordedPerceptionUrl,
|
||||||
|
fetchRecordedPerceptionRrd,
|
||||||
isRecordedPlaybackFullyBuffered,
|
isRecordedPlaybackFullyBuffered,
|
||||||
} = await server.ssrLoadModule(
|
} = await server.ssrLoadModule(
|
||||||
"/src/components/RerunViewport.tsx",
|
"/src/components/RerunViewport.tsx",
|
||||||
|
|
@ -265,6 +269,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||||
{
|
{
|
||||||
origin: "http://127.0.0.1:5174",
|
origin: "http://127.0.0.1:5174",
|
||||||
|
activeView: "perception",
|
||||||
fetcher: async (input, init) => {
|
fetcher: async (input, init) => {
|
||||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||||
return new Response(payload, {
|
return new Response(payload, {
|
||||||
|
|
@ -287,6 +292,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||||
point_size: 4.5,
|
point_size: 4.5,
|
||||||
palette: "custom",
|
palette: "custom",
|
||||||
custom_color: "#35d7c1",
|
custom_color: "#35d7c1",
|
||||||
|
active_view: "perception",
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
|
|
@ -308,6 +314,49 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
"http://127.0.0.1:5174",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
endpoint,
|
||||||
|
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/perception.rrd",
|
||||||
|
);
|
||||||
|
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||||
|
const result = await fetchRecordedPerceptionRrd(
|
||||||
|
endpoint,
|
||||||
|
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||||
|
{
|
||||||
|
origin: "http://127.0.0.1:5174",
|
||||||
|
fetcher: async (_input, init) => {
|
||||||
|
assert.deepEqual(JSON.parse(String(init.body)), {
|
||||||
|
application_id: "nodedc_mission_core_recorded",
|
||||||
|
recording_id: "recording-001",
|
||||||
|
});
|
||||||
|
return new Response(payload, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/vnd.rerun.rrd",
|
||||||
|
"Content-Length": String(payload.byteLength),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual([...result], [...payload]);
|
||||||
|
|
||||||
|
const absent = await fetchRecordedPerceptionRrd(
|
||||||
|
endpoint,
|
||||||
|
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||||
|
{
|
||||||
|
origin: "http://127.0.0.1:5174",
|
||||||
|
fetcher: async () => new Response(null, { status: 204 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(absent, null);
|
||||||
|
});
|
||||||
|
|
||||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||||
const sources = recordedObservationSources({
|
const sources = recordedObservationSources({
|
||||||
kind: "rerun-recording",
|
kind: "rerun-recording",
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,24 @@ from .jobs import (
|
||||||
prepare_camera_compute_job,
|
prepare_camera_compute_job,
|
||||||
validate_camera_compute_job,
|
validate_camera_compute_job,
|
||||||
)
|
)
|
||||||
|
from .results import (
|
||||||
|
DetectionFrame,
|
||||||
|
ObjectDetection,
|
||||||
|
RecordedPerceptionOverlayError,
|
||||||
|
RecordedPerceptionOverlayStore,
|
||||||
|
RecordedPerceptionResult,
|
||||||
|
validate_recorded_perception_result,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"COMPUTE_JOB_SCHEMA",
|
"COMPUTE_JOB_SCHEMA",
|
||||||
"CameraComputeJob",
|
"CameraComputeJob",
|
||||||
"prepare_camera_compute_job",
|
"prepare_camera_compute_job",
|
||||||
"validate_camera_compute_job",
|
"validate_camera_compute_job",
|
||||||
|
"DetectionFrame",
|
||||||
|
"ObjectDetection",
|
||||||
|
"RecordedPerceptionOverlayError",
|
||||||
|
"RecordedPerceptionOverlayStore",
|
||||||
|
"RecordedPerceptionResult",
|
||||||
|
"validate_recorded_perception_result",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ class CameraComputeJob:
|
||||||
job_id: str
|
job_id: str
|
||||||
job_root: Path
|
job_root: Path
|
||||||
manifest_path: Path
|
manifest_path: Path
|
||||||
|
session_id: str
|
||||||
|
source_id: str
|
||||||
|
codec_epoch: int
|
||||||
input_sha256: str
|
input_sha256: str
|
||||||
input_byte_length: int
|
input_byte_length: int
|
||||||
segment_count: int
|
segment_count: int
|
||||||
|
|
@ -170,11 +173,14 @@ def validate_camera_compute_job(job_root: Path) -> CameraComputeJob:
|
||||||
raise SessionIntegrityError("compute job input generation is inconsistent")
|
raise SessionIntegrityError("compute job input generation is inconsistent")
|
||||||
|
|
||||||
source_id = input_document.get("source_id")
|
source_id = input_document.get("source_id")
|
||||||
|
session_id = input_document.get("session_id")
|
||||||
codec_epoch = input_document.get("codec_epoch")
|
codec_epoch = input_document.get("codec_epoch")
|
||||||
timeline = input_document.get("timeline")
|
timeline = input_document.get("timeline")
|
||||||
files = input_document.get("files")
|
files = input_document.get("files")
|
||||||
if (
|
if (
|
||||||
not isinstance(source_id, str)
|
not isinstance(session_id, str)
|
||||||
|
or _SAFE_COMPONENT.fullmatch(session_id) is None
|
||||||
|
or not isinstance(source_id, str)
|
||||||
or _SAFE_COMPONENT.fullmatch(source_id) is None
|
or _SAFE_COMPONENT.fullmatch(source_id) is None
|
||||||
or not isinstance(codec_epoch, int)
|
or not isinstance(codec_epoch, int)
|
||||||
or isinstance(codec_epoch, bool)
|
or isinstance(codec_epoch, bool)
|
||||||
|
|
@ -235,6 +241,9 @@ def validate_camera_compute_job(job_root: Path) -> CameraComputeJob:
|
||||||
job_id=root.name,
|
job_id=root.name,
|
||||||
job_root=root,
|
job_root=root,
|
||||||
manifest_path=manifest_path,
|
manifest_path=manifest_path,
|
||||||
|
session_id=session_id,
|
||||||
|
source_id=source_id,
|
||||||
|
codec_epoch=codec_epoch,
|
||||||
input_sha256=input_sha256,
|
input_sha256=input_sha256,
|
||||||
input_byte_length=total_bytes,
|
input_byte_length=total_bytes,
|
||||||
segment_count=segment_count,
|
segment_count=segment_count,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,656 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, TypeGuard
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rerun as rr
|
||||||
|
|
||||||
|
from k1link.artifacts import write_json_atomic
|
||||||
|
from k1link.sessions import SessionIntegrityError
|
||||||
|
|
||||||
|
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||||
|
|
||||||
|
COMPUTE_RESULT_SCHEMA = "missioncore.compute-result/v1"
|
||||||
|
COMPUTE_RESULT_IDENTITY_SCHEMA = "missioncore.compute-result-identity/v1"
|
||||||
|
OBJECT_DETECTIONS_SCHEMA = "missioncore.object-detections/v1"
|
||||||
|
SESSION_TIMELINE = "session_time"
|
||||||
|
|
||||||
|
MAX_RESULT_JSON_BYTES = 32 * 1024 * 1024
|
||||||
|
MAX_RESULT_FRAMES = 10_000
|
||||||
|
MAX_DETECTIONS_PER_FRAME = 10_000
|
||||||
|
MAX_OVERLAY_SOURCE_BYTES = 512 * 1024 * 1024
|
||||||
|
MAX_OVERLAY_DECODED_BYTES = 512 * 1024 * 1024
|
||||||
|
MAX_OVERLAY_RRD_BYTES = 512 * 1024 * 1024
|
||||||
|
MAX_JOB_SCAN = 512
|
||||||
|
|
||||||
|
_SAFE_RESULT_ID = re.compile(r"^result-[a-f0-9]{64}$")
|
||||||
|
_SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||||
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ObjectDetection:
|
||||||
|
class_id: int
|
||||||
|
class_name: str
|
||||||
|
score: float
|
||||||
|
bbox_xyxy: tuple[float, float, float, float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DetectionFrame:
|
||||||
|
frame_index: int
|
||||||
|
session_time_ns: int
|
||||||
|
detections: tuple[ObjectDetection, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RecordedPerceptionResult:
|
||||||
|
result_id: str
|
||||||
|
result_root: Path
|
||||||
|
job: CameraComputeJob
|
||||||
|
created_at_utc: str
|
||||||
|
frames: tuple[DetectionFrame, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedPerceptionOverlayError(RuntimeError):
|
||||||
|
"""A validated compute result could not be projected into Rerun."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_recorded_perception_result(
|
||||||
|
job_root: Path,
|
||||||
|
result_root: Path,
|
||||||
|
) -> RecordedPerceptionResult:
|
||||||
|
"""Validate one content-addressed worker result against its exact job."""
|
||||||
|
|
||||||
|
job = validate_camera_compute_job(job_root)
|
||||||
|
root = result_root.expanduser().resolve(strict=True)
|
||||||
|
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||||
|
raise SessionIntegrityError("compute result root is invalid")
|
||||||
|
|
||||||
|
result = _read_json_object(root / "result.json", root)
|
||||||
|
detections = _read_json_object(root / "detections.json", root)
|
||||||
|
identity_sha256 = result.get("identity_sha256")
|
||||||
|
pipeline = result.get("pipeline")
|
||||||
|
model = result.get("model")
|
||||||
|
parameters = result.get("parameters")
|
||||||
|
if (
|
||||||
|
result.get("schema_version") != COMPUTE_RESULT_SCHEMA
|
||||||
|
or result.get("result_id") != root.name
|
||||||
|
or not isinstance(identity_sha256, str)
|
||||||
|
or _SHA256.fullmatch(identity_sha256) is None
|
||||||
|
or root.name != f"result-{identity_sha256}"
|
||||||
|
or result.get("job_id") != job.job_id
|
||||||
|
or result.get("input_sha256") != job.input_sha256
|
||||||
|
or not isinstance(pipeline, dict)
|
||||||
|
or not isinstance(model, dict)
|
||||||
|
or not isinstance(parameters, dict)
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("compute result identity is inconsistent")
|
||||||
|
identity = {
|
||||||
|
"schema_version": COMPUTE_RESULT_IDENTITY_SCHEMA,
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"pipeline": pipeline,
|
||||||
|
"model": {
|
||||||
|
"id": model.get("id"),
|
||||||
|
"version": model.get("version"),
|
||||||
|
"sha256": model.get("sha256"),
|
||||||
|
},
|
||||||
|
"parameters": parameters,
|
||||||
|
}
|
||||||
|
if hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256:
|
||||||
|
raise SessionIntegrityError("compute result generation is inconsistent")
|
||||||
|
|
||||||
|
artifacts = result.get("artifacts")
|
||||||
|
detection_path = root / "detections.json"
|
||||||
|
detection_stat = _confined_regular_file(detection_path, root)
|
||||||
|
if not isinstance(artifacts, list) or len(artifacts) != 1:
|
||||||
|
raise SessionIntegrityError("compute result artifact descriptor is invalid")
|
||||||
|
artifact = artifacts[0]
|
||||||
|
if (
|
||||||
|
not isinstance(artifact, dict)
|
||||||
|
or artifact.get("kind") != "object-detections"
|
||||||
|
or artifact.get("path") != "detections.json"
|
||||||
|
or artifact.get("schema_version") != OBJECT_DETECTIONS_SCHEMA
|
||||||
|
or artifact.get("byte_length") != detection_stat.st_size
|
||||||
|
or artifact.get("sha256") != _sha256_file(detection_path)
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("compute result artifact identity changed")
|
||||||
|
|
||||||
|
if (
|
||||||
|
detections.get("schema_version") != OBJECT_DETECTIONS_SCHEMA
|
||||||
|
or detections.get("result_id") != root.name
|
||||||
|
or detections.get("job_id") != job.job_id
|
||||||
|
or detections.get("input_sha256") != job.input_sha256
|
||||||
|
or detections.get("timestamp_basis") != "session-time-seconds"
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("object detections are not bound to the result")
|
||||||
|
raw_frames = detections.get("frames")
|
||||||
|
if not isinstance(raw_frames, list) or not 1 <= len(raw_frames) <= MAX_RESULT_FRAMES:
|
||||||
|
raise SessionIntegrityError("object detection frame set is outside bounds")
|
||||||
|
|
||||||
|
frames: list[DetectionFrame] = []
|
||||||
|
detection_count = 0
|
||||||
|
previous_time_ns = -1
|
||||||
|
for expected_index, value in enumerate(raw_frames):
|
||||||
|
frame = _validate_detection_frame(value, expected_index, job)
|
||||||
|
if frame.session_time_ns <= previous_time_ns:
|
||||||
|
raise SessionIntegrityError("object detection timestamps are not increasing")
|
||||||
|
previous_time_ns = frame.session_time_ns
|
||||||
|
frames.append(frame)
|
||||||
|
detection_count += len(frame.detections)
|
||||||
|
|
||||||
|
metrics = result.get("metrics")
|
||||||
|
if (
|
||||||
|
not isinstance(metrics, dict)
|
||||||
|
or metrics.get("frames_processed") != len(frames)
|
||||||
|
or metrics.get("detections") != detection_count
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("compute result metrics do not match its artifact")
|
||||||
|
created_at_utc = result.get("created_at_utc")
|
||||||
|
if not isinstance(created_at_utc, str) or len(created_at_utc) > 64:
|
||||||
|
raise SessionIntegrityError("compute result creation time is invalid")
|
||||||
|
return RecordedPerceptionResult(
|
||||||
|
result_id=root.name,
|
||||||
|
result_root=root,
|
||||||
|
job=job,
|
||||||
|
created_at_utc=created_at_utc,
|
||||||
|
frames=tuple(frames),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedPerceptionOverlayStore:
|
||||||
|
"""Discover admitted local results and cache complete overlay RRD files."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
jobs_root: Path,
|
||||||
|
results_root: Path,
|
||||||
|
cache_root: Path,
|
||||||
|
ffmpeg_path: Path,
|
||||||
|
ffprobe_path: Path,
|
||||||
|
) -> None:
|
||||||
|
self.jobs_root = jobs_root.expanduser().absolute()
|
||||||
|
self.results_root = results_root.expanduser().absolute()
|
||||||
|
self.cache_root = cache_root.expanduser().absolute()
|
||||||
|
self.ffmpeg_path = ffmpeg_path.expanduser().resolve(strict=True)
|
||||||
|
self.ffprobe_path = ffprobe_path.expanduser().resolve(strict=True)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
application_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
) -> bytes | None:
|
||||||
|
if _SAFE_RECORDING_ID.fullmatch(session_id) is None:
|
||||||
|
raise ValueError("observation session id is invalid")
|
||||||
|
if application_id != "nodedc_mission_core_recorded":
|
||||||
|
raise ValueError("recorded perception application id is invalid")
|
||||||
|
if _SAFE_RECORDING_ID.fullmatch(recording_id) is None:
|
||||||
|
raise ValueError("recorded perception recording id is invalid")
|
||||||
|
with self._lock:
|
||||||
|
result = self._latest_result(session_id)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
cache_root = _private_directory(self.cache_root)
|
||||||
|
session_cache = _private_child_directory(cache_root, session_id)
|
||||||
|
cache_dir = _private_child_directory(session_cache, result.result_id)
|
||||||
|
output = cache_dir / f"{recording_id}.rrd"
|
||||||
|
sidecar = output.with_suffix(".rrd.cache.json")
|
||||||
|
cached = _read_cached_overlay(output, sidecar, result)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
payload = _render_overlay(
|
||||||
|
result,
|
||||||
|
application_id=application_id,
|
||||||
|
recording_id=recording_id,
|
||||||
|
ffmpeg_path=self.ffmpeg_path,
|
||||||
|
ffprobe_path=self.ffprobe_path,
|
||||||
|
)
|
||||||
|
temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("xb") as stream:
|
||||||
|
stream.write(payload)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.chmod(temporary, 0o600)
|
||||||
|
os.replace(temporary, output)
|
||||||
|
write_json_atomic(
|
||||||
|
sidecar,
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.perception-overlay-cache/v1",
|
||||||
|
"result_id": result.result_id,
|
||||||
|
"recording_id": recording_id,
|
||||||
|
"byte_length": len(payload),
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _latest_result(self, session_id: str) -> RecordedPerceptionResult | None:
|
||||||
|
try:
|
||||||
|
job_roots = sorted(self.jobs_root.iterdir())
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if len(job_roots) > MAX_JOB_SCAN:
|
||||||
|
raise RecordedPerceptionOverlayError("compute job catalog is outside bounds")
|
||||||
|
matches: list[RecordedPerceptionResult] = []
|
||||||
|
for job_root in job_roots:
|
||||||
|
if job_root.is_symlink():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
job = validate_camera_compute_job(job_root)
|
||||||
|
except (OSError, SessionIntegrityError):
|
||||||
|
continue
|
||||||
|
if job.session_id != session_id:
|
||||||
|
continue
|
||||||
|
result_parent = self.results_root / job.job_id
|
||||||
|
try:
|
||||||
|
result_roots = sorted(
|
||||||
|
path
|
||||||
|
for path in result_parent.iterdir()
|
||||||
|
if path.is_dir()
|
||||||
|
and not path.is_symlink()
|
||||||
|
and _SAFE_RESULT_ID.fullmatch(path.name) is not None
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
for result_root in result_roots:
|
||||||
|
matches.append(validate_recorded_perception_result(job_root, result_root))
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
return max(matches, key=lambda value: (value.created_at_utc, value.result_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_detection_frame(
|
||||||
|
value: object,
|
||||||
|
expected_index: int,
|
||||||
|
job: CameraComputeJob,
|
||||||
|
) -> DetectionFrame:
|
||||||
|
if not isinstance(value, dict) or value.get("frame_index") != expected_index:
|
||||||
|
raise SessionIntegrityError("object detection frame index is inconsistent")
|
||||||
|
session_seconds = value.get("session_seconds")
|
||||||
|
epoch_seconds = value.get("epoch_seconds")
|
||||||
|
raw_detections = value.get("detections")
|
||||||
|
if (
|
||||||
|
not _finite_number(session_seconds)
|
||||||
|
or not _finite_number(epoch_seconds)
|
||||||
|
or float(epoch_seconds) < 0
|
||||||
|
or float(session_seconds) < job.timeline_start_seconds - 0.001
|
||||||
|
or float(session_seconds) > job.timeline_end_seconds + 0.001
|
||||||
|
or not isinstance(raw_detections, list)
|
||||||
|
or len(raw_detections) > MAX_DETECTIONS_PER_FRAME
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("object detection frame is invalid")
|
||||||
|
found = tuple(_validate_detection(item) for item in raw_detections)
|
||||||
|
return DetectionFrame(
|
||||||
|
frame_index=expected_index,
|
||||||
|
session_time_ns=round(float(session_seconds) * 1_000_000_000),
|
||||||
|
detections=found,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_detection(value: object) -> ObjectDetection:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SessionIntegrityError("object detection is invalid")
|
||||||
|
class_id = value.get("class_id")
|
||||||
|
class_name = value.get("class_name")
|
||||||
|
score = value.get("score")
|
||||||
|
bbox = value.get("bbox_xyxy")
|
||||||
|
if (
|
||||||
|
not isinstance(class_id, int)
|
||||||
|
or isinstance(class_id, bool)
|
||||||
|
or not 0 <= class_id <= 10_000
|
||||||
|
or not isinstance(class_name, str)
|
||||||
|
or not class_name
|
||||||
|
or len(class_name) > 128
|
||||||
|
or not _finite_number(score)
|
||||||
|
or not 0 <= float(score) <= 1
|
||||||
|
or not isinstance(bbox, list)
|
||||||
|
or len(bbox) != 4
|
||||||
|
or not all(_finite_number(item) for item in bbox)
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("object detection fields are invalid")
|
||||||
|
x1, y1, x2, y2 = (float(item) for item in bbox)
|
||||||
|
if min(x1, y1) < 0 or x2 < x1 or y2 < y1 or max(x2, y2) > 100_000:
|
||||||
|
raise SessionIntegrityError("object detection bounds are invalid")
|
||||||
|
return ObjectDetection(class_id, class_name, float(score), (x1, y1, x2, y2))
|
||||||
|
|
||||||
|
|
||||||
|
def _render_overlay(
|
||||||
|
result: RecordedPerceptionResult,
|
||||||
|
*,
|
||||||
|
application_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
ffmpeg_path: Path,
|
||||||
|
ffprobe_path: Path,
|
||||||
|
) -> bytes:
|
||||||
|
if validate_camera_compute_job(result.job.job_root) != result.job:
|
||||||
|
raise RecordedPerceptionOverlayError("camera compute job changed before projection")
|
||||||
|
source = _camera_source_bytes(result.job)
|
||||||
|
width, height, frame_count = _probe_video(source, ffprobe_path)
|
||||||
|
if frame_count != len(result.frames):
|
||||||
|
raise RecordedPerceptionOverlayError("camera and detection frame counts differ")
|
||||||
|
_validate_image_bounds(result, width, height)
|
||||||
|
frame_bytes = width * height * 3
|
||||||
|
expected_bytes = frame_bytes * len(result.frames)
|
||||||
|
if expected_bytes > MAX_OVERLAY_DECODED_BYTES:
|
||||||
|
raise RecordedPerceptionOverlayError("decoded camera overlay is outside bounds")
|
||||||
|
decoded = _decode_rgb(source, ffmpeg_path, frame_count)
|
||||||
|
if len(decoded) != expected_bytes:
|
||||||
|
raise RecordedPerceptionOverlayError("decoded camera frame count changed")
|
||||||
|
|
||||||
|
recording = rr.RecordingStream(
|
||||||
|
application_id,
|
||||||
|
recording_id=recording_id,
|
||||||
|
send_properties=False,
|
||||||
|
)
|
||||||
|
stream = rr.binary_stream(recording)
|
||||||
|
try:
|
||||||
|
recording.log(
|
||||||
|
"/perception/camera/metadata",
|
||||||
|
rr.AnyValues(
|
||||||
|
result_id=result.result_id,
|
||||||
|
job_id=result.job.job_id,
|
||||||
|
source_id=result.job.source_id,
|
||||||
|
pipeline="recorded-camera-coco-detection/v1",
|
||||||
|
warning="Generic detections are evidence, not a driving decision.",
|
||||||
|
),
|
||||||
|
static=True,
|
||||||
|
)
|
||||||
|
for frame in result.frames:
|
||||||
|
offset = frame.frame_index * frame_bytes
|
||||||
|
image = np.frombuffer(
|
||||||
|
decoded,
|
||||||
|
dtype=np.uint8,
|
||||||
|
count=frame_bytes,
|
||||||
|
offset=offset,
|
||||||
|
).reshape((height, width, 3))
|
||||||
|
recording.set_time(
|
||||||
|
SESSION_TIMELINE,
|
||||||
|
duration=np.timedelta64(frame.session_time_ns, "ns"),
|
||||||
|
)
|
||||||
|
recording.log(
|
||||||
|
"/perception/camera/image",
|
||||||
|
rr.Image(image, color_model="RGB"),
|
||||||
|
)
|
||||||
|
if frame.detections:
|
||||||
|
recording.log(
|
||||||
|
"/perception/camera/detections",
|
||||||
|
rr.Boxes2D(
|
||||||
|
array=[detection.bbox_xyxy for detection in frame.detections],
|
||||||
|
array_format=rr.Box2DFormat.XYXY,
|
||||||
|
labels=[
|
||||||
|
f"{detection.class_name} · {detection.score:.0%}"
|
||||||
|
for detection in frame.detections
|
||||||
|
],
|
||||||
|
colors=[
|
||||||
|
_class_color(detection.class_id)
|
||||||
|
for detection in frame.detections
|
||||||
|
],
|
||||||
|
show_labels=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
recording.log(
|
||||||
|
"/perception/camera/detections",
|
||||||
|
rr.Clear(recursive=False),
|
||||||
|
)
|
||||||
|
payload = stream.read(flush=True, flush_timeout_sec=120.0)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RecordedPerceptionOverlayError("failed to serialize perception overlay") from exc
|
||||||
|
finally:
|
||||||
|
recording.disconnect()
|
||||||
|
if payload is None or not payload.startswith(b"RRF2") or len(payload) > MAX_OVERLAY_RRD_BYTES:
|
||||||
|
raise RecordedPerceptionOverlayError("serialized perception overlay is invalid")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_source_bytes(job: CameraComputeJob) -> bytes:
|
||||||
|
epoch = (
|
||||||
|
job.job_root
|
||||||
|
/ "input"
|
||||||
|
/ "camera"
|
||||||
|
/ job.source_id
|
||||||
|
/ f"epoch-{job.codec_epoch}"
|
||||||
|
)
|
||||||
|
paths = [epoch / "init.mp4"] + [
|
||||||
|
epoch / "segments" / f"{sequence}.m4s"
|
||||||
|
for sequence in range(1, job.segment_count + 1)
|
||||||
|
]
|
||||||
|
total = sum(path.stat().st_size for path in paths)
|
||||||
|
if total > MAX_OVERLAY_SOURCE_BYTES:
|
||||||
|
raise RecordedPerceptionOverlayError("camera overlay source is outside bounds")
|
||||||
|
return b"".join(path.read_bytes() for path in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_video(source: bytes, ffprobe_path: Path) -> tuple[int, int, int]:
|
||||||
|
completed = _run_media_tool(
|
||||||
|
[
|
||||||
|
str(ffprobe_path),
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-count_frames",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=width,height,nb_read_frames",
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
],
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
document = json.loads(completed.stdout)
|
||||||
|
stream = document["streams"][0]
|
||||||
|
width = int(stream["width"])
|
||||||
|
height = int(stream["height"])
|
||||||
|
frame_count = int(stream["nb_read_frames"])
|
||||||
|
except (IndexError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise RecordedPerceptionOverlayError("camera dimensions are unavailable") from exc
|
||||||
|
if (
|
||||||
|
width < 1
|
||||||
|
or height < 1
|
||||||
|
or width * height > 4_194_304
|
||||||
|
or not 1 <= frame_count <= MAX_RESULT_FRAMES
|
||||||
|
):
|
||||||
|
raise RecordedPerceptionOverlayError("camera dimensions are outside bounds")
|
||||||
|
return width, height, frame_count
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_rgb(source: bytes, ffmpeg_path: Path, frame_count: int) -> bytes:
|
||||||
|
return _run_media_tool(
|
||||||
|
[
|
||||||
|
str(ffmpeg_path),
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-map",
|
||||||
|
"0:v:0",
|
||||||
|
"-frames:v",
|
||||||
|
str(frame_count),
|
||||||
|
"-fps_mode",
|
||||||
|
"passthrough",
|
||||||
|
"-pix_fmt",
|
||||||
|
"rgb24",
|
||||||
|
"-f",
|
||||||
|
"rawvideo",
|
||||||
|
"pipe:1",
|
||||||
|
],
|
||||||
|
source,
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _run_media_tool(argv: list[str], payload: bytes) -> subprocess.CompletedProcess[bytes]:
|
||||||
|
try:
|
||||||
|
metadata = Path(argv[0]).lstat()
|
||||||
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||||
|
raise RecordedPerceptionOverlayError("media tool is not a regular file")
|
||||||
|
completed = subprocess.run(
|
||||||
|
argv,
|
||||||
|
input=payload,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
raise RecordedPerceptionOverlayError("media tool failed") from exc
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RecordedPerceptionOverlayError("camera media could not be decoded")
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cached_overlay(
|
||||||
|
output: Path,
|
||||||
|
sidecar: Path,
|
||||||
|
result: RecordedPerceptionResult,
|
||||||
|
) -> bytes | None:
|
||||||
|
try:
|
||||||
|
value = _read_json_object(sidecar, sidecar.parent)
|
||||||
|
_confined_regular_file(output, output.parent)
|
||||||
|
payload = output.read_bytes()
|
||||||
|
except (OSError, SessionIntegrityError):
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
value.get("schema_version") != "missioncore.perception-overlay-cache/v1"
|
||||||
|
or value.get("result_id") != result.result_id
|
||||||
|
or value.get("recording_id") != output.stem
|
||||||
|
or value.get("byte_length") != len(payload)
|
||||||
|
or value.get("sha256") != hashlib.sha256(payload).hexdigest()
|
||||||
|
or not payload.startswith(b"RRF2")
|
||||||
|
or len(payload) > MAX_OVERLAY_RRD_BYTES
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_image_bounds(
|
||||||
|
result: RecordedPerceptionResult,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> None:
|
||||||
|
for frame in result.frames:
|
||||||
|
for detection in frame.detections:
|
||||||
|
x1, y1, x2, y2 = detection.bbox_xyxy
|
||||||
|
if x1 > width or x2 > width or y1 > height or y2 > height:
|
||||||
|
raise RecordedPerceptionOverlayError(
|
||||||
|
"object detection escapes the decoded camera image"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _private_directory(path: Path) -> Path:
|
||||||
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
metadata = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise RecordedPerceptionOverlayError("perception cache is unavailable") from exc
|
||||||
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||||
|
raise RecordedPerceptionOverlayError("perception cache is not a real directory")
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
os.chmod(resolved, 0o700)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _private_child_directory(parent: Path, name: str) -> Path:
|
||||||
|
child = parent / name
|
||||||
|
child.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
try:
|
||||||
|
metadata = child.lstat()
|
||||||
|
resolved = child.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RecordedPerceptionOverlayError("perception cache child is unavailable") from exc
|
||||||
|
if (
|
||||||
|
stat.S_ISLNK(metadata.st_mode)
|
||||||
|
or not stat.S_ISDIR(metadata.st_mode)
|
||||||
|
or resolved.parent != parent
|
||||||
|
):
|
||||||
|
raise RecordedPerceptionOverlayError("perception cache child escapes its root")
|
||||||
|
os.chmod(resolved, 0o700)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _class_color(class_id: int) -> list[int]:
|
||||||
|
palette = (
|
||||||
|
(185, 255, 74, 255),
|
||||||
|
(67, 191, 255, 255),
|
||||||
|
(255, 193, 92, 255),
|
||||||
|
(222, 110, 255, 255),
|
||||||
|
(255, 103, 117, 255),
|
||||||
|
)
|
||||||
|
return list(palette[class_id % len(palette)])
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_object(path: Path, root: Path) -> dict[str, Any]:
|
||||||
|
metadata = _confined_regular_file(path, root)
|
||||||
|
if not 0 < metadata.st_size <= MAX_RESULT_JSON_BYTES:
|
||||||
|
raise SessionIntegrityError("compute result JSON is outside bounds")
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise SessionIntegrityError("compute result JSON is unavailable") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SessionIntegrityError("compute result JSON is not an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||||
|
try:
|
||||||
|
resolved_root = root.resolve(strict=True)
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
metadata = path.lstat()
|
||||||
|
except OSError as exc:
|
||||||
|
raise SessionIntegrityError("compute result artifact is unavailable") from exc
|
||||||
|
if (
|
||||||
|
stat.S_ISLNK(metadata.st_mode)
|
||||||
|
or not stat.S_ISREG(metadata.st_mode)
|
||||||
|
or not resolved.is_relative_to(resolved_root)
|
||||||
|
):
|
||||||
|
raise SessionIntegrityError("compute result artifact is not a confined regular file")
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
try:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise SessionIntegrityError("compute result identity cannot be encoded") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_number(value: object) -> TypeGuard[int | float]:
|
||||||
|
return (
|
||||||
|
isinstance(value, (int, float))
|
||||||
|
and not isinstance(value, bool)
|
||||||
|
and math.isfinite(float(value))
|
||||||
|
)
|
||||||
|
|
@ -8,7 +8,7 @@ from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import Literal, TypedDict
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -60,7 +60,9 @@ JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||||
|
RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
|
||||||
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
||||||
|
RecordedView = Literal["spatial", "perception", "metrics"]
|
||||||
|
|
||||||
|
|
||||||
class RrdExportSummary(TypedDict):
|
class RrdExportSummary(TypedDict):
|
||||||
|
|
@ -496,6 +498,7 @@ def _recorded_blueprint(
|
||||||
settings: RerunSceneSettings,
|
settings: RerunSceneSettings,
|
||||||
*,
|
*,
|
||||||
include_initial_playback_state: bool = True,
|
include_initial_playback_state: bool = True,
|
||||||
|
active_view: RecordedView = "spatial",
|
||||||
) -> rrb.Blueprint:
|
) -> rrb.Blueprint:
|
||||||
accumulation = max(0.0, settings.accumulation_seconds)
|
accumulation = max(0.0, settings.accumulation_seconds)
|
||||||
# An omitted range means Rerun's native latest-at query: the most recent
|
# An omitted range means Rerun's native latest-at query: the most recent
|
||||||
|
|
@ -551,12 +554,24 @@ def _recorded_blueprint(
|
||||||
time_ranges=time_ranges,
|
time_ranges=time_ranges,
|
||||||
)
|
)
|
||||||
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||||
|
camera_view = rrb.Spatial2DView(
|
||||||
|
origin="/perception/camera",
|
||||||
|
name="Камера · распознавание",
|
||||||
|
background=[7, 8, 10, 255],
|
||||||
|
)
|
||||||
|
camera_view.id = RECORDED_CAMERA_VIEW_ID
|
||||||
metrics_view = rrb.TimeSeriesView(
|
metrics_view = rrb.TimeSeriesView(
|
||||||
origin="/metrics/device",
|
origin="/metrics/device",
|
||||||
name="Маршрут и время",
|
name="Маршрут и время",
|
||||||
)
|
)
|
||||||
metrics_view.id = RECORDED_METRICS_VIEW_ID
|
metrics_view.id = RECORDED_METRICS_VIEW_ID
|
||||||
root_container = rrb.Tabs(spatial_view, metrics_view, active_tab=0)
|
active_tab = {"spatial": 0, "perception": 1, "metrics": 2}[active_view]
|
||||||
|
root_container = rrb.Tabs(
|
||||||
|
spatial_view,
|
||||||
|
camera_view,
|
||||||
|
metrics_view,
|
||||||
|
active_tab=active_tab,
|
||||||
|
)
|
||||||
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||||
|
|
||||||
if include_initial_playback_state:
|
if include_initial_playback_state:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import rerun as rr
|
import rerun as rr
|
||||||
|
|
@ -16,6 +17,9 @@ SESSION_TIMELINE = "session_time"
|
||||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||||
|
RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
|
||||||
|
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
||||||
|
RecordedView = Literal["spatial", "perception", "metrics"]
|
||||||
|
|
||||||
|
|
||||||
class RecordedBlueprintError(RuntimeError):
|
class RecordedBlueprintError(RuntimeError):
|
||||||
|
|
@ -26,6 +30,7 @@ def recorded_blueprint(
|
||||||
settings: RerunSceneSettings,
|
settings: RerunSceneSettings,
|
||||||
*,
|
*,
|
||||||
include_initial_playback_state: bool = True,
|
include_initial_playback_state: bool = True,
|
||||||
|
active_view: RecordedView = "spatial",
|
||||||
) -> rrb.Blueprint:
|
) -> rrb.Blueprint:
|
||||||
accumulation = max(0.0, settings.accumulation_seconds)
|
accumulation = max(0.0, settings.accumulation_seconds)
|
||||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||||
|
|
@ -67,7 +72,24 @@ def recorded_blueprint(
|
||||||
time_ranges=time_ranges,
|
time_ranges=time_ranges,
|
||||||
)
|
)
|
||||||
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||||
root_container = rrb.Tabs(spatial_view)
|
camera_view = rrb.Spatial2DView(
|
||||||
|
origin="/perception/camera",
|
||||||
|
name="Камера · распознавание",
|
||||||
|
background=[7, 8, 10, 255],
|
||||||
|
)
|
||||||
|
camera_view.id = RECORDED_CAMERA_VIEW_ID
|
||||||
|
metrics_view = rrb.TimeSeriesView(
|
||||||
|
origin="/metrics/device",
|
||||||
|
name="Маршрут и время",
|
||||||
|
)
|
||||||
|
metrics_view.id = RECORDED_METRICS_VIEW_ID
|
||||||
|
active_tab = {"spatial": 0, "perception": 1, "metrics": 2}[active_view]
|
||||||
|
root_container = rrb.Tabs(
|
||||||
|
spatial_view,
|
||||||
|
camera_view,
|
||||||
|
metrics_view,
|
||||||
|
active_tab=active_tab,
|
||||||
|
)
|
||||||
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||||
|
|
||||||
if include_initial_playback_state:
|
if include_initial_playback_state:
|
||||||
|
|
@ -95,6 +117,7 @@ def recorded_blueprint_rrd(
|
||||||
*,
|
*,
|
||||||
application_id: str = APPLICATION_ID,
|
application_id: str = APPLICATION_ID,
|
||||||
recording_id: str,
|
recording_id: str,
|
||||||
|
active_view: RecordedView = "spatial",
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Serialize a bounded active blueprint update without recorded data."""
|
"""Serialize a bounded active blueprint update without recorded data."""
|
||||||
|
|
||||||
|
|
@ -109,6 +132,7 @@ def recorded_blueprint_rrd(
|
||||||
recorded_blueprint(
|
recorded_blueprint(
|
||||||
settings,
|
settings,
|
||||||
include_initial_playback_state=False,
|
include_initial_playback_state=False,
|
||||||
|
active_view=active_view,
|
||||||
),
|
),
|
||||||
make_active=True,
|
make_active=True,
|
||||||
make_default=False,
|
make_default=False,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import shutil
|
||||||
from collections.abc import AsyncIterator, Iterable
|
from collections.abc import AsyncIterator, Iterable
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -13,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from k1link import __version__
|
from k1link import __version__
|
||||||
|
from k1link.compute import RecordedPerceptionOverlayStore
|
||||||
from k1link.sessions import (
|
from k1link.sessions import (
|
||||||
MaterializedRecording,
|
MaterializedRecording,
|
||||||
RecordedMediaInspector,
|
RecordedMediaInspector,
|
||||||
|
|
@ -51,6 +53,19 @@ session_recording_materializer = SessionRecordingMaterializer(
|
||||||
session_recorded_media_inspector = RecordedMediaInspector(
|
session_recorded_media_inspector = RecordedMediaInspector(
|
||||||
session_store.data_dir / "recorded-media-preparations"
|
session_store.data_dir / "recorded-media-preparations"
|
||||||
)
|
)
|
||||||
|
_ffmpeg = shutil.which("ffmpeg")
|
||||||
|
_ffprobe = shutil.which("ffprobe")
|
||||||
|
session_perception_overlay_store = (
|
||||||
|
RecordedPerceptionOverlayStore(
|
||||||
|
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||||
|
results_root=REPOSITORY_ROOT / ".runtime" / "compute-results",
|
||||||
|
cache_root=session_store.data_dir / "perception-overlays",
|
||||||
|
ffmpeg_path=Path(_ffmpeg),
|
||||||
|
ffprobe_path=Path(_ffprobe),
|
||||||
|
)
|
||||||
|
if _ffmpeg is not None and _ffprobe is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _prepare_recorded_media_for_launch(
|
def _prepare_recorded_media_for_launch(
|
||||||
|
|
@ -304,6 +319,7 @@ app.include_router(
|
||||||
recording_materializer=session_recording_materializer,
|
recording_materializer=session_recording_materializer,
|
||||||
recording_preparation_manager=session_recording_preparation_manager,
|
recording_preparation_manager=session_recording_preparation_manager,
|
||||||
media_inspector=session_recorded_media_inspector,
|
media_inspector=session_recorded_media_inspector,
|
||||||
|
perception_overlay_provider=session_perception_overlay_store,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator,
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
from starlette.types import Receive, Scope, Send
|
from starlette.types import Receive, Scope, Send
|
||||||
|
|
||||||
|
from k1link.compute import RecordedPerceptionOverlayError
|
||||||
from k1link.sessions import (
|
from k1link.sessions import (
|
||||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||||
LayoutConflictError,
|
LayoutConflictError,
|
||||||
|
|
@ -102,6 +103,16 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||||
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
||||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
active_view: Literal["spatial", "perception", "metrics"] = "spatial"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedPerceptionRequest(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}$",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SceneSettingsDocument(StrictApiModel):
|
class SceneSettingsDocument(StrictApiModel):
|
||||||
|
|
@ -233,6 +244,16 @@ class CatalogRefresher(Protocol):
|
||||||
def __call__(self) -> object: ...
|
def __call__(self) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedPerceptionOverlayProvider(Protocol):
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
application_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
) -> bytes | None: ...
|
||||||
|
|
||||||
|
|
||||||
def build_session_router(
|
def build_session_router(
|
||||||
store: SessionStore,
|
store: SessionStore,
|
||||||
*,
|
*,
|
||||||
|
|
@ -241,6 +262,7 @@ def build_session_router(
|
||||||
recording_materializer: RecordingMaterializer | None = None,
|
recording_materializer: RecordingMaterializer | None = None,
|
||||||
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
||||||
media_inspector: RecordedMediaInspector | None = None,
|
media_inspector: RecordedMediaInspector | None = None,
|
||||||
|
perception_overlay_provider: RecordedPerceptionOverlayProvider | None = None,
|
||||||
allow_synchronous_recording_fallback: bool = False,
|
allow_synchronous_recording_fallback: bool = False,
|
||||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
|
|
@ -755,6 +777,7 @@ def build_session_router(
|
||||||
),
|
),
|
||||||
application_id=RECORDED_APPLICATION_ID,
|
application_id=RECORDED_APPLICATION_ID,
|
||||||
recording_id=request.recording_id,
|
recording_id=request.recording_id,
|
||||||
|
active_view=request.active_view,
|
||||||
)
|
)
|
||||||
except SessionNotFoundError as exc:
|
except SessionNotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
@ -780,6 +803,56 @@ def build_session_router(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.post("/api/v1/observation-sessions/{session_id}/perception.rrd")
|
||||||
|
async def get_observation_session_perception(
|
||||||
|
session_id: str,
|
||||||
|
request: RecordedPerceptionRequest,
|
||||||
|
) -> Response:
|
||||||
|
if perception_overlay_provider is None:
|
||||||
|
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
||||||
|
try:
|
||||||
|
await run_in_threadpool(
|
||||||
|
_prepare_replay,
|
||||||
|
store,
|
||||||
|
catalog_refresher,
|
||||||
|
session_id,
|
||||||
|
1.0,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
payload = await run_in_threadpool(
|
||||||
|
perception_overlay_provider.render,
|
||||||
|
session_id,
|
||||||
|
application_id=request.application_id,
|
||||||
|
recording_id=request.recording_id,
|
||||||
|
)
|
||||||
|
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 RecordedPerceptionOverlayError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Не удалось подготовить слой распознавания.",
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Некорректный идентификатор сессии.",
|
||||||
|
) from exc
|
||||||
|
if payload is None:
|
||||||
|
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
||||||
|
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="perception.rrd"',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/api/v1/observation-sessions/{session_id}/media/{artifact_id}/manifest")
|
@router.get("/api/v1/observation-sessions/{session_id}/media/{artifact_id}/manifest")
|
||||||
def get_recorded_media_manifest(
|
def get_recorded_media_manifest(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import k1link.compute.results as result_module
|
||||||
|
from k1link.compute import (
|
||||||
|
RecordedPerceptionOverlayStore,
|
||||||
|
prepare_camera_compute_job,
|
||||||
|
validate_recorded_perception_result,
|
||||||
|
)
|
||||||
|
from k1link.sessions import SessionIntegrityError
|
||||||
|
from k1link.web.camera_archive import CameraArchiveWriter
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _job(tmp_path: Path) -> Any:
|
||||||
|
origin_epoch_ns = 1_000_000_000
|
||||||
|
origin_monotonic_ns = 2_000_000_000
|
||||||
|
session = tmp_path / "session-1"
|
||||||
|
session.mkdir()
|
||||||
|
|
||||||
|
def box(box_type: bytes, payload: bytes = b"") -> bytes:
|
||||||
|
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
|
||||||
|
|
||||||
|
def full_box(box_type: bytes, payload: bytes = b"", *, flags: int = 0) -> bytes:
|
||||||
|
return box(box_type, bytes([0]) + flags.to_bytes(3, "big") + payload)
|
||||||
|
|
||||||
|
track_id = 1
|
||||||
|
tkhd = full_box(b"tkhd", b"\x00" * 8 + track_id.to_bytes(4, "big") + b"\x00" * 4)
|
||||||
|
mdhd = full_box(
|
||||||
|
b"mdhd",
|
||||||
|
b"\x00" * 8 + (1_000).to_bytes(4, "big") + b"\x00" * 4,
|
||||||
|
)
|
||||||
|
hdlr = full_box(b"hdlr", b"\x00" * 4 + b"vide")
|
||||||
|
trak = box(b"trak", tkhd + box(b"mdia", mdhd + hdlr))
|
||||||
|
trex = full_box(
|
||||||
|
b"trex",
|
||||||
|
track_id.to_bytes(4, "big")
|
||||||
|
+ (1).to_bytes(4, "big")
|
||||||
|
+ (500).to_bytes(4, "big")
|
||||||
|
+ b"\x00" * 8,
|
||||||
|
)
|
||||||
|
init = box(b"ftyp", b"isom") + box(
|
||||||
|
b"moov",
|
||||||
|
trak + box(b"mvex", trex) + box(b"avcC", b"\x01\x64\x00\x28"),
|
||||||
|
)
|
||||||
|
tfhd = full_box(b"tfhd", track_id.to_bytes(4, "big"), flags=0x020000)
|
||||||
|
tfdt = full_box(b"tfdt", (0).to_bytes(4, "big"))
|
||||||
|
trun = full_box(b"trun", (1).to_bytes(4, "big"))
|
||||||
|
fragment = box(b"moof", box(b"traf", tfhd + tfdt + trun)) + box(b"mdat", b"frame")
|
||||||
|
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
|
||||||
|
writer.append(
|
||||||
|
"init",
|
||||||
|
init,
|
||||||
|
host_epoch_ns=origin_epoch_ns + 100_000_000,
|
||||||
|
host_monotonic_ns=origin_monotonic_ns + 100_000_000,
|
||||||
|
)
|
||||||
|
writer.append(
|
||||||
|
"media",
|
||||||
|
fragment,
|
||||||
|
host_epoch_ns=origin_epoch_ns + 200_000_000,
|
||||||
|
host_monotonic_ns=origin_monotonic_ns + 200_000_000,
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
return prepare_camera_compute_job(
|
||||||
|
session_root=session,
|
||||||
|
source_id="sensor.camera.left",
|
||||||
|
codec_epoch=1,
|
||||||
|
origin_epoch_ns=origin_epoch_ns,
|
||||||
|
origin_monotonic_ns=origin_monotonic_ns,
|
||||||
|
output_root=tmp_path / "jobs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(tmp_path: Path, job: Any) -> Path:
|
||||||
|
parameters = {
|
||||||
|
"score_threshold": 0.25,
|
||||||
|
"nms_threshold": 0.45,
|
||||||
|
"input_shape": [1, 3, 640, 640],
|
||||||
|
}
|
||||||
|
pipeline = {"id": "recorded-camera-coco-detection", "version": 1}
|
||||||
|
model = {
|
||||||
|
"id": "synthetic",
|
||||||
|
"version": 1,
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"source_url": "https://example.invalid/model.onnx",
|
||||||
|
"license": "test-only",
|
||||||
|
"classes": "synthetic",
|
||||||
|
}
|
||||||
|
identity = {
|
||||||
|
"schema_version": "missioncore.compute-result-identity/v1",
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"pipeline": pipeline,
|
||||||
|
"model": {key: model[key] for key in ("id", "version", "sha256")},
|
||||||
|
"parameters": parameters,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
result_id = f"result-{identity_sha256}"
|
||||||
|
root = tmp_path / "results" / job.job_id / result_id
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
detections = {
|
||||||
|
"schema_version": "missioncore.object-detections/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"timestamp_basis": "session-time-seconds",
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"frame_index": 0,
|
||||||
|
"epoch_seconds": 0.0,
|
||||||
|
"session_seconds": job.timeline_start_seconds,
|
||||||
|
"detections": [
|
||||||
|
{
|
||||||
|
"class_id": 0,
|
||||||
|
"class_name": "person",
|
||||||
|
"score": 0.75,
|
||||||
|
"bbox_xyxy": [10.0, 20.0, 30.0, 60.0],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
detection_payload = json.dumps(detections, indent=2).encode() + b"\n"
|
||||||
|
(root / "detections.json").write_bytes(detection_payload)
|
||||||
|
result = {
|
||||||
|
"schema_version": "missioncore.compute-result/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"created_at_utc": "2026-07-19T00:00:00.000Z",
|
||||||
|
"pipeline": pipeline,
|
||||||
|
"runtime": {"kind": "synthetic"},
|
||||||
|
"model": model,
|
||||||
|
"parameters": parameters,
|
||||||
|
"metrics": {"frames_processed": 1, "detections": 1},
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"kind": "object-detections",
|
||||||
|
"path": "detections.json",
|
||||||
|
"byte_length": len(detection_payload),
|
||||||
|
"sha256": hashlib.sha256(detection_payload).hexdigest(),
|
||||||
|
"schema_version": "missioncore.object-detections/v1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
(root / "result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_is_bound_to_job_and_cache_reuses_complete_overlay(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
job = _job(tmp_path)
|
||||||
|
result_root = _result(tmp_path, job)
|
||||||
|
result = validate_recorded_perception_result(job.job_root, result_root)
|
||||||
|
assert result.job.session_id == "session-1"
|
||||||
|
assert result.frames[0].detections[0].class_name == "person"
|
||||||
|
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def render(*_: object, **__: object) -> bytes:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return b"RRF2synthetic-overlay"
|
||||||
|
|
||||||
|
monkeypatch.setattr(result_module, "_render_overlay", render)
|
||||||
|
store = RecordedPerceptionOverlayStore(
|
||||||
|
jobs_root=tmp_path / "jobs",
|
||||||
|
results_root=tmp_path / "results",
|
||||||
|
cache_root=tmp_path / "cache",
|
||||||
|
ffmpeg_path=Path(__file__),
|
||||||
|
ffprobe_path=Path(__file__),
|
||||||
|
)
|
||||||
|
first = store.render(
|
||||||
|
"session-1",
|
||||||
|
application_id="nodedc_mission_core_recorded",
|
||||||
|
recording_id="recording-1",
|
||||||
|
)
|
||||||
|
second = store.render(
|
||||||
|
"session-1",
|
||||||
|
application_id="nodedc_mission_core_recorded",
|
||||||
|
recording_id="recording-1",
|
||||||
|
)
|
||||||
|
assert first == second == b"RRF2synthetic-overlay"
|
||||||
|
assert calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_result_rejects_changed_detection_payload(tmp_path: Path) -> None:
|
||||||
|
job = _job(tmp_path)
|
||||||
|
result_root = _result(tmp_path, job)
|
||||||
|
detection_path = result_root / "detections.json"
|
||||||
|
detection_path.write_bytes(detection_path.read_bytes().replace(b"0.75", b"0.76"))
|
||||||
|
with pytest.raises(SessionIntegrityError, match="artifact identity changed"):
|
||||||
|
validate_recorded_perception_result(job.job_root, result_root)
|
||||||
|
|
@ -17,6 +17,7 @@ from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||||
RAW_MAGIC,
|
RAW_MAGIC,
|
||||||
)
|
)
|
||||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||||
|
RECORDED_CAMERA_VIEW_ID,
|
||||||
RECORDED_METRICS_VIEW_ID,
|
RECORDED_METRICS_VIEW_ID,
|
||||||
RECORDED_POINTS_VISUALIZER_ID,
|
RECORDED_POINTS_VISUALIZER_ID,
|
||||||
RECORDED_ROOT_CONTAINER_ID,
|
RECORDED_ROOT_CONTAINER_ID,
|
||||||
|
|
@ -232,8 +233,10 @@ def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
|
||||||
first_view = first.root_container.contents[0]
|
first_view = first.root_container.contents[0]
|
||||||
second_view = second.root_container.contents[0]
|
second_view = second.root_container.contents[0]
|
||||||
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
|
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
|
||||||
assert first.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
|
assert first.root_container.contents[1].id == RECORDED_CAMERA_VIEW_ID
|
||||||
assert second.root_container.contents[1].id == RECORDED_METRICS_VIEW_ID
|
assert second.root_container.contents[1].id == RECORDED_CAMERA_VIEW_ID
|
||||||
|
assert first.root_container.contents[2].id == RECORDED_METRICS_VIEW_ID
|
||||||
|
assert second.root_container.contents[2].id == RECORDED_METRICS_VIEW_ID
|
||||||
|
|
||||||
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides["/world/points"]
|
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides["/world/points"]
|
||||||
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
|
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from k1link.web.camera_archive import CameraArchiveWriter
|
||||||
from k1link.web.session_api import (
|
from k1link.web.session_api import (
|
||||||
LayoutPutRequest,
|
LayoutPutRequest,
|
||||||
RecordedBlueprintRequest,
|
RecordedBlueprintRequest,
|
||||||
|
RecordedPerceptionRequest,
|
||||||
ReplayRequest,
|
ReplayRequest,
|
||||||
build_session_router,
|
build_session_router,
|
||||||
)
|
)
|
||||||
|
|
@ -1036,9 +1037,11 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||||
observed_settings: list[RerunSceneSettings] = []
|
observed_settings: list[RerunSceneSettings] = []
|
||||||
|
observed_kwargs: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def capture_settings(settings: RerunSceneSettings, **kwargs: Any) -> bytes:
|
def capture_settings(settings: RerunSceneSettings, **kwargs: Any) -> bytes:
|
||||||
observed_settings.append(settings)
|
observed_settings.append(settings)
|
||||||
|
observed_kwargs.append(kwargs)
|
||||||
return recorded_blueprint_rrd(settings, **kwargs)
|
return recorded_blueprint_rrd(settings, **kwargs)
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -1065,6 +1068,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||||
point_size=6.25,
|
point_size=6.25,
|
||||||
palette="custom",
|
palette="custom",
|
||||||
custom_color="#112233",
|
custom_color="#112233",
|
||||||
|
active_view="perception",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -1078,6 +1082,7 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||||
assert len(observed_settings) == 1
|
assert len(observed_settings) == 1
|
||||||
assert observed_settings[0].show_points is False
|
assert observed_settings[0].show_points is False
|
||||||
assert observed_settings[0].show_trajectory is True
|
assert observed_settings[0].show_trajectory is True
|
||||||
|
assert observed_kwargs[0]["active_view"] == "perception"
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
RecordedBlueprintRequest.model_validate(
|
RecordedBlueprintRequest.model_validate(
|
||||||
|
|
@ -1106,6 +1111,67 @@ def test_recorded_blueprint_endpoint_is_small_strict_and_session_scoped(
|
||||||
assert missing.value.status_code == 404
|
assert missing.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
|
||||||
|
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]] = []
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def render(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
application_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
) -> bytes:
|
||||||
|
calls.append((session_id, application_id, recording_id))
|
||||||
|
return b"RRF2perception"
|
||||||
|
|
||||||
|
router = build_session_router(store, perception_overlay_provider=Provider())
|
||||||
|
perception_route = endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/observation-sessions/{session_id}/perception.rrd",
|
||||||
|
"POST",
|
||||||
|
)
|
||||||
|
response = asyncio.run(
|
||||||
|
perception_route(
|
||||||
|
session_id=session.name,
|
||||||
|
request=RecordedPerceptionRequest(
|
||||||
|
application_id="nodedc_mission_core_recorded",
|
||||||
|
recording_id="recording-001",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert response.body == b"RRF2perception"
|
||||||
|
assert response.media_type == "application/vnd.rerun.rrd"
|
||||||
|
assert response.headers["content-length"] == str(len(response.body))
|
||||||
|
assert calls == [
|
||||||
|
(session.name, "nodedc_mission_core_recorded", "recording-001")
|
||||||
|
]
|
||||||
|
|
||||||
|
empty_router = build_session_router(store)
|
||||||
|
empty_route = endpoint(
|
||||||
|
empty_router,
|
||||||
|
"/api/v1/observation-sessions/{session_id}/perception.rrd",
|
||||||
|
"POST",
|
||||||
|
)
|
||||||
|
empty = asyncio.run(
|
||||||
|
empty_route(
|
||||||
|
session_id=session.name,
|
||||||
|
request=RecordedPerceptionRequest(
|
||||||
|
application_id="nodedc_mission_core_recorded",
|
||||||
|
recording_id="recording-001",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert empty.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
|
def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue