Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ffb829c9 | ||
|
|
757d368c86 | ||
|
|
f1cbe0061a | ||
|
|
74da6437e9 | ||
|
|
bd2892140f | ||
|
|
525ab74168 | ||
|
|
c30d77572e | ||
|
|
5179e93f4a | ||
|
|
c06b709fd7 | ||
|
|
856b61be99 | ||
|
|
76694cc869 | ||
|
|
e6df8cb264 | ||
|
|
4b487e367f | ||
|
|
d7d9485724 | ||
|
|
0a0cde7b0e | ||
|
|
9dec37a8a9 | ||
|
|
24b65926fe | ||
|
|
57dacc5a04 | ||
|
|
d9ec8c9cef | ||
|
|
af1e530162 |
@@ -29,7 +29,9 @@ export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "
|
|||||||
|
|
||||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||||
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
|
const RECORDED_MEDIA_SOURCE_OPEN_TIMEOUT_MS = 10_000;
|
||||||
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 10_000;
|
// A valid local fragment becomes decoder-ready well below one second. Keeping
|
||||||
|
// a damaged GOP on screen for ten seconds only delays the keyframe recovery.
|
||||||
|
const RECORDED_MEDIA_TARGET_TIMEOUT_MS = 2_500;
|
||||||
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
|
const RECORDED_MEDIA_FRAGMENT_TIMEOUT_MS = 15_000;
|
||||||
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
|
const RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS = 12;
|
||||||
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
|
const RECORDED_MEDIA_SEGMENTS_AHEAD = 36;
|
||||||
@@ -72,6 +74,39 @@ export function recordedMediaDecodeStartSequence(
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nextRecordedMediaRandomAccessSequence(
|
||||||
|
randomAccessSequences: readonly number[],
|
||||||
|
failedSequence: number,
|
||||||
|
): number | null {
|
||||||
|
if (!Number.isInteger(failedSequence) || failedSequence < 1) return null;
|
||||||
|
for (const sequence of randomAccessSequences) {
|
||||||
|
if (!Number.isInteger(sequence) || sequence < 1) return null;
|
||||||
|
if (sequence > failedSequence) return sequence;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedMediaRecoveryTargetSequence(
|
||||||
|
requestedSequence: number | null,
|
||||||
|
failedSequence: number | null,
|
||||||
|
recoverySequence: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (requestedSequence === null) return null;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(requestedSequence)
|
||||||
|
|| requestedSequence < 1
|
||||||
|
|| failedSequence === null
|
||||||
|
|| recoverySequence === null
|
||||||
|
|| !Number.isInteger(failedSequence)
|
||||||
|
|| !Number.isInteger(recoverySequence)
|
||||||
|
|| failedSequence < 1
|
||||||
|
|| recoverySequence <= failedSequence
|
||||||
|
) return requestedSequence;
|
||||||
|
return requestedSequence >= failedSequence && requestedSequence < recoverySequence
|
||||||
|
? recoverySequence
|
||||||
|
: requestedSequence;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaSegmentAppendOrder(
|
export function recordedMediaSegmentAppendOrder(
|
||||||
appended: ReadonlySet<number>,
|
appended: ReadonlySet<number>,
|
||||||
decodeStartSequence: number,
|
decodeStartSequence: number,
|
||||||
@@ -90,6 +125,32 @@ export function recordedMediaSegmentAppendOrder(
|
|||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a source-clock timestamp to the first fMP4 fragment covering it. */
|
||||||
|
export function recordedMediaSegmentSequenceAtTime(
|
||||||
|
segmentEndTimesSeconds: readonly number[],
|
||||||
|
epochStartSeconds: number,
|
||||||
|
currentSeconds: number,
|
||||||
|
): number | null {
|
||||||
|
if (
|
||||||
|
!segmentEndTimesSeconds.length ||
|
||||||
|
!Number.isFinite(epochStartSeconds) ||
|
||||||
|
!Number.isFinite(currentSeconds)
|
||||||
|
) return null;
|
||||||
|
const localSeconds = Math.max(0, currentSeconds - epochStartSeconds);
|
||||||
|
let left = 0;
|
||||||
|
let right = segmentEndTimesSeconds.length - 1;
|
||||||
|
while (left < right) {
|
||||||
|
const middle = Math.floor((left + right) / 2);
|
||||||
|
const endSeconds = segmentEndTimesSeconds[middle];
|
||||||
|
if (!Number.isFinite(endSeconds) || endSeconds <= 0) return null;
|
||||||
|
if (endSeconds + 0.001 >= localSeconds) right = middle;
|
||||||
|
else left = middle + 1;
|
||||||
|
}
|
||||||
|
const finalEndSeconds = segmentEndTimesSeconds[left];
|
||||||
|
if (!Number.isFinite(finalEndSeconds) || finalEndSeconds + 0.001 < localSeconds) return null;
|
||||||
|
return left + 1;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaCanRollTarget(
|
export function recordedMediaCanRollTarget(
|
||||||
previousSequence: number,
|
previousSequence: number,
|
||||||
nextSequence: number,
|
nextSequence: number,
|
||||||
@@ -121,6 +182,27 @@ function recordedMediaTimeRangesContain(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function recordedMediaTimestampStallRecoveryTarget(
|
||||||
|
currentSeconds: number,
|
||||||
|
bufferedRanges: readonly (readonly [number, number])[],
|
||||||
|
skipSeconds = 0.18,
|
||||||
|
): number | null {
|
||||||
|
if (!Number.isFinite(currentSeconds) || !Number.isFinite(skipSeconds) || skipSeconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const [startSeconds, endSeconds] of bufferedRanges) {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(startSeconds)
|
||||||
|
|| !Number.isFinite(endSeconds)
|
||||||
|
|| currentSeconds < startSeconds - 0.05
|
||||||
|
|| currentSeconds > endSeconds
|
||||||
|
) continue;
|
||||||
|
const targetSeconds = Math.min(currentSeconds + skipSeconds, endSeconds - 0.05);
|
||||||
|
return targetSeconds >= currentSeconds + 0.04 ? targetSeconds : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaPresentationState(
|
export function recordedMediaPresentationState(
|
||||||
state: "loading" | "ready" | "error",
|
state: "loading" | "ready" | "error",
|
||||||
readyGeneration: string | null,
|
readyGeneration: string | null,
|
||||||
@@ -151,6 +233,24 @@ export function selectRecordedMediaEpoch(
|
|||||||
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
|
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep admission bounded even when the shared Rerun clock is currently before,
|
||||||
|
* between, or after camera epochs. Presentation still reports `waiting`; this
|
||||||
|
* selector only chooses the nearest epoch whose first/last fragment can prove
|
||||||
|
* that the camera transport is usable without downloading the whole MP4.
|
||||||
|
*/
|
||||||
|
export function selectRecordedMediaPreparationEpoch(
|
||||||
|
epochs: readonly ObservationRecordedMediaEpoch[],
|
||||||
|
currentSeconds: number,
|
||||||
|
): ObservationRecordedMediaEpoch | null {
|
||||||
|
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
|
||||||
|
const active = selectRecordedMediaEpoch(epochs, currentSeconds);
|
||||||
|
if (active) return active;
|
||||||
|
return epochs.find((epoch) => epoch.timelineStartSeconds > currentSeconds)
|
||||||
|
?? epochs.at(-1)
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function recordedMediaSeekableCoverage(
|
export function recordedMediaSeekableCoverage(
|
||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
seekableEndSeconds: number,
|
seekableEndSeconds: number,
|
||||||
@@ -231,29 +331,14 @@ export async function fetchRecordedMediaArchive(
|
|||||||
return { manifest, byteLength: totalBytes };
|
return { manifest, byteLength: totalBytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function videoHasSeekableArchive(
|
function waitForRecordedVideoInitialFrame(
|
||||||
video: HTMLVideoElement,
|
video: HTMLVideoElement,
|
||||||
declaredDurationSeconds: number,
|
|
||||||
): boolean {
|
|
||||||
if (video.readyState < 1 || video.seekable.length < 1) return false;
|
|
||||||
return recordedMediaSeekableCoverage(
|
|
||||||
video.duration,
|
|
||||||
video.seekable.end(video.seekable.length - 1),
|
|
||||||
declaredDurationSeconds,
|
|
||||||
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
|
|
||||||
video.seekable.start(0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForSeekableArchive(
|
|
||||||
video: HTMLVideoElement,
|
|
||||||
declaredDurationSeconds: number,
|
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||||
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
|
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
|
const events = ["loadeddata", "canplay", "progress"] as const;
|
||||||
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||||
const armStallTimer = () => {
|
const armStallTimer = () => {
|
||||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||||
@@ -270,7 +355,7 @@ function waitForSeekableArchive(
|
|||||||
};
|
};
|
||||||
const onProgress = () => {
|
const onProgress = () => {
|
||||||
armStallTimer();
|
armStallTimer();
|
||||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
@@ -313,11 +398,7 @@ async function mountRecordedEpochStream(
|
|||||||
};
|
};
|
||||||
video.load();
|
video.load();
|
||||||
try {
|
try {
|
||||||
await waitForSeekableArchive(
|
await waitForRecordedVideoInitialFrame(video, signal);
|
||||||
video,
|
|
||||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -336,6 +417,11 @@ interface RecordedSegmentTarget {
|
|||||||
resetAttempts: number;
|
resetAttempts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RecordedSegmentRecovery {
|
||||||
|
readonly failedSequence: number;
|
||||||
|
readonly recoverySequence: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RecordedSegmentStreamRuntime {
|
interface RecordedSegmentStreamRuntime {
|
||||||
readonly generation: string;
|
readonly generation: string;
|
||||||
readonly mediaSource: MediaSource;
|
readonly mediaSource: MediaSource;
|
||||||
@@ -716,6 +802,9 @@ export function RecordedFmp4Player({
|
|||||||
onAdmissionChange,
|
onAdmissionChange,
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
onPlayingRejected,
|
onPlayingRejected,
|
||||||
|
playbackAuthority = "media",
|
||||||
|
playbackTransport = "segmented",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
playback?: RecordedObservationPlayback | null;
|
playback?: RecordedObservationPlayback | null;
|
||||||
@@ -728,6 +817,9 @@ export function RecordedFmp4Player({
|
|||||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||||
onPlayingRejected?: () => void;
|
onPlayingRejected?: () => void;
|
||||||
|
playbackAuthority?: "media" | "host";
|
||||||
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
const onAdmissionChangeRef = useRef(onAdmissionChange);
|
||||||
@@ -768,12 +860,16 @@ export function RecordedFmp4Player({
|
|||||||
);
|
);
|
||||||
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
||||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||||
const [bufferRevision, setBufferRevision] = useState(0);
|
const [bufferRevision, setBufferRevision] = useState(0);
|
||||||
|
const [segmentRecoveryGeneration, setSegmentRecoveryGeneration] = useState(0);
|
||||||
|
const [segmentRecovery, setSegmentRecovery] = useState<RecordedSegmentRecovery | null>(null);
|
||||||
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
|
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
|
||||||
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
|
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
|
||||||
const targetRevisionRef = useRef(0);
|
const targetRevisionRef = useRef(0);
|
||||||
const targetReadyAbortRef = useRef<AbortController | null>(null);
|
const targetReadyAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const lastSegmentRecoveryRef = useRef<string | null>(null);
|
||||||
const playAttemptRevisionRef = useRef(0);
|
const playAttemptRevisionRef = useRef(0);
|
||||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||||
const playbackPlayingRef = useRef(Boolean(playback?.playing));
|
const playbackPlayingRef = useRef(Boolean(playback?.playing));
|
||||||
@@ -781,25 +877,63 @@ export function RecordedFmp4Player({
|
|||||||
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
const playbackRate = playback?.rate && Number.isFinite(playback.rate)
|
||||||
? Math.min(4, Math.max(0.25, playback.rate))
|
? Math.min(4, Math.max(0.25, playback.rate))
|
||||||
: 1;
|
: 1;
|
||||||
const epoch = useMemo(
|
const playbackRateRef = useRef(playbackRate);
|
||||||
|
playbackRateRef.current = playbackRate;
|
||||||
|
const presentationEpoch = useMemo(
|
||||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||||
[archive?.manifest.epochs, currentSeconds],
|
[archive?.manifest.epochs, currentSeconds],
|
||||||
);
|
);
|
||||||
|
const epoch = useMemo(
|
||||||
|
() => selectRecordedMediaPreparationEpoch(
|
||||||
|
archive?.manifest.epochs ?? [],
|
||||||
|
currentSeconds,
|
||||||
|
),
|
||||||
|
[archive?.manifest.epochs, currentSeconds],
|
||||||
|
);
|
||||||
|
const segmentClockSeconds = epoch
|
||||||
|
? Math.min(
|
||||||
|
Math.max(currentSeconds, epoch.timelineStartSeconds),
|
||||||
|
epoch.timelineEndSeconds,
|
||||||
|
)
|
||||||
|
: currentSeconds;
|
||||||
|
const effectiveSegmentCount = segmentCount ?? epoch?.segmentCount ?? null;
|
||||||
|
const requestedSegmentSequence = segmentSequence ?? (epoch
|
||||||
|
? recordedMediaSegmentSequenceAtTime(
|
||||||
|
epoch.segmentEndTimesSeconds,
|
||||||
|
epoch.timelineStartSeconds,
|
||||||
|
segmentClockSeconds,
|
||||||
|
)
|
||||||
|
: null);
|
||||||
|
const effectiveSegmentSequence = recordedMediaRecoveryTargetSequence(
|
||||||
|
requestedSegmentSequence,
|
||||||
|
segmentRecovery?.failedSequence ?? null,
|
||||||
|
segmentRecovery?.recoverySequence ?? null,
|
||||||
|
);
|
||||||
|
const holdingForSegmentRecovery = Boolean(
|
||||||
|
segmentRecovery
|
||||||
|
&& requestedSegmentSequence !== null
|
||||||
|
&& effectiveSegmentSequence !== requestedSegmentSequence,
|
||||||
|
);
|
||||||
const segmented = Boolean(
|
const segmented = Boolean(
|
||||||
segmentCount !== null
|
playbackTransport === "segmented"
|
||||||
&& Number.isInteger(segmentCount)
|
&& requestedSegmentSequence !== null
|
||||||
&& segmentCount >= 1
|
&& Number.isInteger(requestedSegmentSequence)
|
||||||
|
&& requestedSegmentSequence >= 1
|
||||||
|
&&
|
||||||
|
effectiveSegmentCount !== null
|
||||||
|
&& Number.isInteger(effectiveSegmentCount)
|
||||||
|
&& effectiveSegmentCount >= 1
|
||||||
&& typeof MediaSource !== "undefined"
|
&& typeof MediaSource !== "undefined"
|
||||||
&& epoch
|
&& epoch
|
||||||
&& epoch.segmentCount === segmentCount
|
&& epoch.segmentCount === effectiveSegmentCount
|
||||||
&& epoch.randomAccessSequences.length > 0
|
&& epoch.randomAccessSequences.length > 0
|
||||||
&& epoch.segmentEndTimesSeconds.length === segmentCount
|
&& epoch.segmentEndTimesSeconds.length === effectiveSegmentCount
|
||||||
&& MediaSource.isTypeSupported(epoch.mediaType),
|
&& MediaSource.isTypeSupported(epoch.mediaType),
|
||||||
);
|
);
|
||||||
const directPlaybackSeconds = segmented ? null : currentSeconds;
|
const directPlaybackSeconds = segmented ? null : currentSeconds;
|
||||||
const waitingForEpoch = Boolean(archive && !epoch);
|
const waitingForEpoch = Boolean(archive && !presentationEpoch);
|
||||||
const selectedGeneration = contract && epoch
|
const selectedGeneration = contract && presentationEpoch
|
||||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
? `${contract.manifestGenerationSha256}:${presentationEpoch.ordinal}:${presentationEpoch.timelineStartSeconds}:${presentationEpoch.timelineEndSeconds}`
|
||||||
: null;
|
: null;
|
||||||
const visualState = recordedMediaPresentationState(
|
const visualState = recordedMediaPresentationState(
|
||||||
state,
|
state,
|
||||||
@@ -813,6 +947,7 @@ export function RecordedFmp4Player({
|
|||||||
if (!contract) {
|
if (!contract) {
|
||||||
setArchive(null);
|
setArchive(null);
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Некорректный descriptor записанной камеры.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -825,6 +960,9 @@ export function RecordedFmp4Player({
|
|||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
setArchive(null);
|
setArchive(null);
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setSegmentRecovery(null);
|
||||||
|
lastSegmentRecoveryRef.current = null;
|
||||||
|
setErrorMessage(null);
|
||||||
setState("loading");
|
setState("loading");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "loading",
|
phase: "loading",
|
||||||
@@ -842,6 +980,7 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
setArchive(null);
|
setArchive(null);
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Архив записанной камеры не прошёл проверку.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -853,43 +992,14 @@ export function RecordedFmp4Player({
|
|||||||
}, [admissionKey, contract, prepare]);
|
}, [admissionKey, contract, prepare]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!archive || !contract || !prepare || segmented) return;
|
if (!segmentRecovery || requestedSegmentSequence === null) return;
|
||||||
const abort = new AbortController();
|
|
||||||
let disposed = false;
|
|
||||||
void (async () => {
|
|
||||||
for (const candidate of archive.manifest.epochs) {
|
|
||||||
const probe = document.createElement("video");
|
|
||||||
probe.muted = true;
|
|
||||||
probe.playsInline = true;
|
|
||||||
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
|
|
||||||
cleanup();
|
|
||||||
if (disposed || abort.signal.aborted) return;
|
|
||||||
}
|
|
||||||
if (disposed || abort.signal.aborted) return;
|
|
||||||
reportAdmission({
|
|
||||||
phase: "ready",
|
|
||||||
byteLength: archive.byteLength,
|
|
||||||
message: null,
|
|
||||||
});
|
|
||||||
})().catch((error: unknown) => {
|
|
||||||
if (
|
if (
|
||||||
disposed ||
|
requestedSegmentSequence >= segmentRecovery.failedSequence
|
||||||
abort.signal.aborted ||
|
&& requestedSegmentSequence < segmentRecovery.recoverySequence
|
||||||
(error instanceof DOMException && error.name === "AbortError")
|
|
||||||
) return;
|
) return;
|
||||||
setReadyGeneration(null);
|
lastSegmentRecoveryRef.current = null;
|
||||||
setState("error");
|
setSegmentRecovery(null);
|
||||||
reportAdmission({
|
}, [requestedSegmentSequence, segmentRecovery]);
|
||||||
phase: "error",
|
|
||||||
byteLength: archive.byteLength,
|
|
||||||
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
abort.abort();
|
|
||||||
};
|
|
||||||
}, [admissionKey, archive, contract, prepare, segmented]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -910,6 +1020,7 @@ export function RecordedFmp4Player({
|
|||||||
|
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
setSegmentedRuntimeGeneration(null);
|
setSegmentedRuntimeGeneration(null);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("loading");
|
setState("loading");
|
||||||
video.pause();
|
video.pause();
|
||||||
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
|
const sourceOpened = waitForMediaSourceOpen(mediaSource, abort.signal);
|
||||||
@@ -957,6 +1068,7 @@ export function RecordedFmp4Player({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Покадровый буфер записанной камеры не открылся.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -982,7 +1094,14 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
};
|
};
|
||||||
}, [archive, contract, epoch, segmentCount, segmented]);
|
}, [
|
||||||
|
archive,
|
||||||
|
contract,
|
||||||
|
effectiveSegmentCount,
|
||||||
|
epoch,
|
||||||
|
segmented,
|
||||||
|
segmentRecoveryGeneration,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runtime = segmentedRuntimeRef.current;
|
const runtime = segmentedRuntimeRef.current;
|
||||||
@@ -993,18 +1112,19 @@ export function RecordedFmp4Player({
|
|||||||
|| !archive
|
|| !archive
|
||||||
|| !segmented
|
|| !segmented
|
||||||
|| segmentedRuntimeGeneration !== runtime.generation
|
|| segmentedRuntimeGeneration !== runtime.generation
|
||||||
|| segmentSequence === null
|
|| effectiveSegmentSequence === null
|
||||||
|| !Number.isInteger(segmentSequence)
|
|| !Number.isInteger(effectiveSegmentSequence)
|
||||||
|| segmentSequence < 1
|
|| effectiveSegmentSequence < 1
|
||||||
|| segmentSequence > runtime.segmentCount
|
|| effectiveSegmentSequence > runtime.segmentCount
|
||||||
) return;
|
) return;
|
||||||
const archiveByteLength = archive.byteLength;
|
const archiveByteLength = archive.byteLength;
|
||||||
const decodeStart = recordedMediaDecodeStartSequence(
|
const decodeStart = recordedMediaDecodeStartSequence(
|
||||||
runtime.randomAccessSequences,
|
runtime.randomAccessSequences,
|
||||||
segmentSequence,
|
effectiveSegmentSequence,
|
||||||
);
|
);
|
||||||
if (decodeStart === null) {
|
if (decodeStart === null) {
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Для кадра записанной камеры нет random-access фрагмента.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1015,10 +1135,11 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
const targetSeconds = recordedSegmentStartSeconds(
|
const targetSeconds = recordedSegmentStartSeconds(
|
||||||
runtime.segmentEndTimesSeconds,
|
runtime.segmentEndTimesSeconds,
|
||||||
segmentSequence,
|
effectiveSegmentSequence,
|
||||||
);
|
);
|
||||||
if (targetSeconds === null) {
|
if (targetSeconds === null) {
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Для кадра записанной камеры нет точной media timestamp.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1030,15 +1151,15 @@ export function RecordedFmp4Player({
|
|||||||
const previousTarget = runtime.target;
|
const previousTarget = runtime.target;
|
||||||
const readyEnd = Math.min(
|
const readyEnd = Math.min(
|
||||||
runtime.segmentCount,
|
runtime.segmentCount,
|
||||||
segmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
effectiveSegmentSequence + RECORDED_MEDIA_REQUIRED_AHEAD_SEGMENTS,
|
||||||
);
|
);
|
||||||
const desiredEnd = Math.min(
|
const desiredEnd = Math.min(
|
||||||
runtime.segmentCount,
|
runtime.segmentCount,
|
||||||
segmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
effectiveSegmentSequence + RECORDED_MEDIA_SEGMENTS_AHEAD,
|
||||||
);
|
);
|
||||||
const candidateTarget: RecordedSegmentTarget = {
|
const candidateTarget: RecordedSegmentTarget = {
|
||||||
revision: previousTarget?.revision ?? 0,
|
revision: previousTarget?.revision ?? 0,
|
||||||
sequence: segmentSequence,
|
sequence: effectiveSegmentSequence,
|
||||||
decodeStart,
|
decodeStart,
|
||||||
readyEnd,
|
readyEnd,
|
||||||
desiredEnd,
|
desiredEnd,
|
||||||
@@ -1046,6 +1167,24 @@ export function RecordedFmp4Player({
|
|||||||
forceReset: false,
|
forceReset: false,
|
||||||
resetAttempts: 0,
|
resetAttempts: 0,
|
||||||
};
|
};
|
||||||
|
const recoverFromSegmentFailure = (failedSequence: number): boolean => {
|
||||||
|
const recoverySequence = nextRecordedMediaRandomAccessSequence(
|
||||||
|
runtime.randomAccessSequences,
|
||||||
|
failedSequence,
|
||||||
|
);
|
||||||
|
if (recoverySequence === null) return false;
|
||||||
|
const recoveryToken = `${runtime.generation}:${failedSequence}:${recoverySequence}`;
|
||||||
|
if (lastSegmentRecoveryRef.current === recoveryToken) return true;
|
||||||
|
lastSegmentRecoveryRef.current = recoveryToken;
|
||||||
|
setReadyGeneration(null);
|
||||||
|
setSegmentRecovery({ failedSequence, recoverySequence });
|
||||||
|
setErrorMessage(
|
||||||
|
`Восстанавливаем камеру с ключевого кадра ${recoverySequence}.`,
|
||||||
|
);
|
||||||
|
setState("loading");
|
||||||
|
setSegmentRecoveryGeneration((generation) => generation + 1);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
|
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
|
||||||
previousTarget.sequence,
|
previousTarget.sequence,
|
||||||
candidateTarget.sequence,
|
candidateTarget.sequence,
|
||||||
@@ -1059,21 +1198,62 @@ export function RecordedFmp4Player({
|
|||||||
|| runtime.abort.signal.aborted
|
|| runtime.abort.signal.aborted
|
||||||
|| (error instanceof DOMException && error.name === "AbortError")
|
|| (error instanceof DOMException && error.name === "AbortError")
|
||||||
) return;
|
) return;
|
||||||
|
const failedSequence = runtime.target?.sequence ?? effectiveSegmentSequence;
|
||||||
|
if (failedSequence !== null && recoverFromSegmentFailure(failedSequence)) return;
|
||||||
|
const detail = error instanceof Error && error.message
|
||||||
|
? `: ${error.message}`
|
||||||
|
: ".";
|
||||||
|
const message = `Покадровый фрагмент записанной камеры недоступен${detail}`;
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage(message);
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
byteLength: archiveByteLength,
|
byteLength: archiveByteLength,
|
||||||
message: "Покадровый фрагмент записанной камеры недоступен.",
|
message,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const resumePlaybackIfRequested = async (revision: number) => {
|
||||||
|
if (
|
||||||
|
runtime.disposed
|
||||||
|
|| segmentedRuntimeRef.current !== runtime
|
||||||
|
|| runtime.target?.revision !== revision
|
||||||
|
|| !playbackPlayingRef.current
|
||||||
|
) return;
|
||||||
|
video.playbackRate = playbackRateRef.current;
|
||||||
|
try {
|
||||||
|
await video.play();
|
||||||
|
} catch {
|
||||||
|
if (
|
||||||
|
runtime.disposed
|
||||||
|
|| segmentedRuntimeRef.current !== runtime
|
||||||
|
|| runtime.target?.revision !== revision
|
||||||
|
) return;
|
||||||
|
// Canonical recorded LABs run one host-owned clock for camera and
|
||||||
|
// spatial evidence. A transient MSE play() rejection (commonly a
|
||||||
|
// pause/reset race while the next fragment is admitted) must stay a
|
||||||
|
// decoder concern: the rolling target will retry and catch up.
|
||||||
|
if (playbackAuthority === "host") return;
|
||||||
|
onPlayingRejectedRef.current?.();
|
||||||
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Запуск записанной камеры отклонён браузером.");
|
||||||
|
setState("error");
|
||||||
|
reportAdmission({
|
||||||
|
phase: "error",
|
||||||
|
byteLength: archiveByteLength,
|
||||||
|
message: "Запуск записанной камеры отклонён браузером.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
if (rollingTarget && previousTarget) {
|
if (rollingTarget && previousTarget) {
|
||||||
runtime.target = {
|
runtime.target = {
|
||||||
...candidateTarget,
|
...candidateTarget,
|
||||||
revision: previousTarget.revision,
|
revision: previousTarget.revision,
|
||||||
};
|
};
|
||||||
runtime.onTargetBuffered = null;
|
runtime.onTargetBuffered = null;
|
||||||
void pumpRecordedSegmentWindow(runtime).catch(reportPumpError);
|
void pumpRecordedSegmentWindow(runtime)
|
||||||
|
.then(() => resumePlaybackIfRequested(previousTarget.revision))
|
||||||
|
.catch(reportPumpError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,18 +1308,22 @@ export function RecordedFmp4Player({
|
|||||||
setBufferRevision((revision) => revision + 1);
|
setBufferRevision((revision) => revision + 1);
|
||||||
runtime.hasPresentedFrame = true;
|
runtime.hasPresentedFrame = true;
|
||||||
setReadyGeneration(runtime.generation);
|
setReadyGeneration(runtime.generation);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("ready");
|
setState("ready");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "ready",
|
phase: "ready",
|
||||||
byteLength: archiveByteLength,
|
byteLength: archiveByteLength,
|
||||||
message: null,
|
message: null,
|
||||||
});
|
});
|
||||||
|
await resumePlaybackIfRequested(bufferedTarget.revision);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
targetReadyAbort.signal.aborted
|
targetReadyAbort.signal.aborted
|
||||||
|| (error instanceof DOMException && error.name === "AbortError")
|
|| (error instanceof DOMException && error.name === "AbortError")
|
||||||
) return;
|
) return;
|
||||||
|
if (recoverFromSegmentFailure(bufferedTarget.sequence)) return;
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Кадр записанной камеры не стал decoder-ready.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1160,7 +1344,13 @@ export function RecordedFmp4Player({
|
|||||||
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
|
if (targetReadyAbortRef.current === targetReadyAbort) targetReadyAbortRef.current = null;
|
||||||
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
|
if (runtime.onTargetBuffered === markBuffered) runtime.onTargetBuffered = null;
|
||||||
};
|
};
|
||||||
}, [archive?.byteLength, segmentSequence, segmented, segmentedRuntimeGeneration]);
|
}, [
|
||||||
|
archive?.byteLength,
|
||||||
|
effectiveSegmentSequence,
|
||||||
|
playbackAuthority,
|
||||||
|
segmented,
|
||||||
|
segmentedRuntimeGeneration,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -1170,6 +1360,7 @@ export function RecordedFmp4Player({
|
|||||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||||
: null;
|
: null;
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("loading");
|
setState("loading");
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -1185,7 +1376,13 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
setBufferRevision((revision) => revision + 1);
|
setBufferRevision((revision) => revision + 1);
|
||||||
setReadyGeneration(generation);
|
setReadyGeneration(generation);
|
||||||
|
setErrorMessage(null);
|
||||||
setState("ready");
|
setState("ready");
|
||||||
|
reportAdmission({
|
||||||
|
phase: "ready",
|
||||||
|
byteLength: archive?.byteLength ?? null,
|
||||||
|
message: null,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
disposed ||
|
disposed ||
|
||||||
@@ -1195,11 +1392,12 @@ export function RecordedFmp4Player({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Записанная камера не открыла первый декодируемый кадр.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
byteLength: archive?.byteLength ?? null,
|
byteLength: archive?.byteLength ?? null,
|
||||||
message: "Записанная камера не стала seekable.",
|
message: "Записанная камера не открыла первый декодируемый кадр.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1234,6 +1432,7 @@ export function RecordedFmp4Player({
|
|||||||
video.currentTime = target;
|
video.currentTime = target;
|
||||||
} catch {
|
} catch {
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Seek записанной камеры завершился ошибкой.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1244,11 +1443,13 @@ export function RecordedFmp4Player({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
video.playbackRate = playbackRate;
|
video.playbackRate = playbackRate;
|
||||||
if (playback?.playing) {
|
if (playback?.playing && !holdingForSegmentRecovery) {
|
||||||
void video.play().catch(() => {
|
void video.play().catch(() => {
|
||||||
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
|
if (playAttemptRevisionRef.current !== playAttemptRevision) return;
|
||||||
|
if (playbackAuthority === "host") return;
|
||||||
onPlayingRejectedRef.current?.();
|
onPlayingRejectedRef.current?.();
|
||||||
setReadyGeneration(null);
|
setReadyGeneration(null);
|
||||||
|
setErrorMessage("Запуск записанной камеры отклонён браузером.");
|
||||||
setState("error");
|
setState("error");
|
||||||
reportAdmission({
|
reportAdmission({
|
||||||
phase: "error",
|
phase: "error",
|
||||||
@@ -1264,12 +1465,95 @@ export function RecordedFmp4Player({
|
|||||||
bufferRevision,
|
bufferRevision,
|
||||||
directPlaybackSeconds,
|
directPlaybackSeconds,
|
||||||
epoch,
|
epoch,
|
||||||
|
holdingForSegmentRecovery,
|
||||||
playback?.playing,
|
playback?.playing,
|
||||||
|
playbackAuthority,
|
||||||
playbackRate,
|
playbackRate,
|
||||||
segmented,
|
segmented,
|
||||||
visualState,
|
visualState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !recoverTimestampStalls || !playback?.playing || visualState !== "ready") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let lastSeconds = video.currentTime;
|
||||||
|
let lastProgressAtMs = performance.now();
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
if (
|
||||||
|
!playbackPlayingRef.current
|
||||||
|
|| video.paused
|
||||||
|
|| video.ended
|
||||||
|
|| video.seeking
|
||||||
|
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||||
|
) {
|
||||||
|
lastSeconds = video.currentTime;
|
||||||
|
lastProgressAtMs = performance.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nowMs = performance.now();
|
||||||
|
if (video.currentTime >= lastSeconds + 0.02) {
|
||||||
|
lastSeconds = video.currentTime;
|
||||||
|
lastProgressAtMs = nowMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nowMs - lastProgressAtMs < 1_250) return;
|
||||||
|
const bufferedRanges = Array.from(
|
||||||
|
{ length: video.buffered.length },
|
||||||
|
(_, index) => [video.buffered.start(index), video.buffered.end(index)] as const,
|
||||||
|
);
|
||||||
|
const targetSeconds = recordedMediaTimestampStallRecoveryTarget(
|
||||||
|
video.currentTime,
|
||||||
|
bufferedRanges,
|
||||||
|
);
|
||||||
|
lastProgressAtMs = nowMs;
|
||||||
|
if (targetSeconds === null) return;
|
||||||
|
// Field recordings can contain non-monotonic or corrupt H.264 timestamps.
|
||||||
|
// If decoded time is frozen despite proven buffered media ahead, skip only
|
||||||
|
// the broken timestamp interval and return authority to the media clock.
|
||||||
|
video.currentTime = targetSeconds;
|
||||||
|
lastSeconds = targetSeconds;
|
||||||
|
}, 250);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [playback?.playing, recoverTimestampStalls, visualState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !segmented || !playback?.playing || visualState !== "ready") return;
|
||||||
|
let cancelled = false;
|
||||||
|
const resumeIfDecoderReady = () => {
|
||||||
|
if (cancelled || !playbackPlayingRef.current || !video.paused) return;
|
||||||
|
const runtime = segmentedRuntimeRef.current;
|
||||||
|
const target = runtime?.target;
|
||||||
|
if (
|
||||||
|
!runtime
|
||||||
|
|| !target
|
||||||
|
|| runtime.disposed
|
||||||
|
|| video.seeking
|
||||||
|
|| video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
||||||
|
|| Math.abs(video.currentTime - target.targetSeconds) > 0.25
|
||||||
|
|| !recordedMediaTimeRangesContain(video.buffered, target.targetSeconds)
|
||||||
|
) return;
|
||||||
|
playAttemptRevisionRef.current += 1;
|
||||||
|
const playAttemptRevision = playAttemptRevisionRef.current;
|
||||||
|
video.playbackRate = playbackRateRef.current;
|
||||||
|
void video.play().catch(() => {
|
||||||
|
if (cancelled || playAttemptRevisionRef.current !== playAttemptRevision) return;
|
||||||
|
if (playbackAuthority === "host") return;
|
||||||
|
onPlayingRejectedRef.current?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const queueResume = () => window.queueMicrotask(resumeIfDecoderReady);
|
||||||
|
const events = ["pause", "canplay", "seeked"] as const;
|
||||||
|
for (const event of events) video.addEventListener(event, queueResume);
|
||||||
|
resumeIfDecoderReady();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
for (const event of events) video.removeEventListener(event, queueResume);
|
||||||
|
};
|
||||||
|
}, [bufferRevision, playback?.playing, playbackAuthority, segmented, visualState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
|
||||||
@@ -1327,7 +1611,9 @@ export function RecordedFmp4Player({
|
|||||||
{visualState === "waiting"
|
{visualState === "waiting"
|
||||||
? "Камера на этой позиции ещё не записывалась"
|
? "Камера на этой позиции ещё не записывалась"
|
||||||
: visualState === "error"
|
: visualState === "error"
|
||||||
? "Записанное видео недоступно"
|
? errorMessage ?? "Записанное видео недоступно"
|
||||||
|
: errorMessage
|
||||||
|
? errorMessage
|
||||||
: archive
|
: archive
|
||||||
? "Проверяем seek и codec записанного видео…"
|
? "Проверяем seek и codec записанного видео…"
|
||||||
: "Читаем manifest записанного видео…"}
|
: "Читаем manifest записанного видео…"}
|
||||||
|
|||||||
@@ -11,10 +11,18 @@ import {
|
|||||||
LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS,
|
LIVE_RECEIVER_OPEN_CHECK_INTERVAL_MS,
|
||||||
requestLiveReceiverRecovery,
|
requestLiveReceiverRecovery,
|
||||||
} from "../core/observation/liveReceiverWatchdog";
|
} from "../core/observation/liveReceiverWatchdog";
|
||||||
|
import {
|
||||||
|
claimExclusiveLiveViewer,
|
||||||
|
createReentrantViewerDisposer,
|
||||||
|
isLiveRerunPresentationReady,
|
||||||
|
liveRerunReceiverBindingIdentity,
|
||||||
|
liveTimelineNeedsSynchronization,
|
||||||
|
} from "../core/observation/liveRerunLifecycle";
|
||||||
import {
|
import {
|
||||||
createLiveViewerDiagnosticLifecycle,
|
createLiveViewerDiagnosticLifecycle,
|
||||||
createLiveViewerInstanceId,
|
createLiveViewerInstanceId,
|
||||||
createLiveViewerLineage,
|
createLiveViewerLineage,
|
||||||
|
reloadRecordedViewerAfterStaleModuleFailure,
|
||||||
subscribeToLiveViewerBuildFence,
|
subscribeToLiveViewerBuildFence,
|
||||||
type LiveViewerFailureStage,
|
type LiveViewerFailureStage,
|
||||||
} from "../core/observation/liveViewerDiagnostics";
|
} from "../core/observation/liveViewerDiagnostics";
|
||||||
@@ -22,10 +30,63 @@ import {
|
|||||||
fetchPerceptionPreparationStatus,
|
fetchPerceptionPreparationStatus,
|
||||||
perceptionPreparationMessage,
|
perceptionPreparationMessage,
|
||||||
} from "../core/observation/perceptionPreparation";
|
} from "../core/observation/perceptionPreparation";
|
||||||
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
import {
|
||||||
|
RECORDED_BASE_POINT_COLOR_KEY,
|
||||||
|
canPublishRecordedPlaybackController,
|
||||||
|
createRecordedAutoplayGate,
|
||||||
|
createRecordedOpenWatchdog,
|
||||||
|
} from "../core/observation/recordedRerunLifecycle";
|
||||||
|
import type {
|
||||||
|
RecordedPerceptionLayers,
|
||||||
|
RecordedRerunView,
|
||||||
|
RecordedRrdArtifactDescriptor,
|
||||||
|
RerunPlaybackController,
|
||||||
|
RerunPlaybackState,
|
||||||
|
RerunViewerProfile,
|
||||||
|
RerunViewportStatus,
|
||||||
|
} from "../core/observation/viewerProfile";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
RecordedPerceptionLayers,
|
||||||
|
RecordedRerunView,
|
||||||
|
RecordedRrdArtifactDescriptor,
|
||||||
|
RerunPlaybackController,
|
||||||
|
RerunPlaybackState,
|
||||||
|
RerunViewerProfile,
|
||||||
|
RerunViewportStatus,
|
||||||
|
} from "../core/observation/viewerProfile";
|
||||||
|
export {
|
||||||
|
attemptRecordedAutoplay,
|
||||||
|
canPublishRecordedPlaybackController,
|
||||||
|
createRecordedAutoplayGate,
|
||||||
|
createRecordedOpenWatchdog,
|
||||||
|
isRecordedPlaybackFullyBuffered,
|
||||||
|
isRecordedPlaybackPresentationReady,
|
||||||
|
isRecordedPlaybackReady,
|
||||||
|
isUsableRecordedPlaybackRange,
|
||||||
|
recordedOpenWatchdogTimeoutMs,
|
||||||
|
recordedPlaybackBufferState,
|
||||||
|
recordedPlaybackRangeWhenReady,
|
||||||
|
recordedPointColorKey,
|
||||||
|
rerunPresentationStatus,
|
||||||
|
type RecordedPlaybackBufferState,
|
||||||
|
} from "../core/observation/recordedRerunLifecycle";
|
||||||
|
export {
|
||||||
|
claimExclusiveLiveViewer,
|
||||||
|
createReentrantViewerDisposer,
|
||||||
|
isLiveRerunPresentationReady,
|
||||||
|
liveRerunReceiverBindingIdentity,
|
||||||
|
liveTimelineNeedsSynchronization,
|
||||||
|
} from "../core/observation/liveRerunLifecycle";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isRecordedPlaybackReady,
|
||||||
|
recordedPlaybackBufferState,
|
||||||
|
recordedPlaybackRangeWhenReady,
|
||||||
|
recordedPointColorKey,
|
||||||
|
rerunPresentationStatus,
|
||||||
|
} from "../core/observation/recordedRerunLifecycle";
|
||||||
|
|
||||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
|
||||||
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
|
|
||||||
export type RecordedPerceptionLoadPhase =
|
export type RecordedPerceptionLoadPhase =
|
||||||
| "idle"
|
| "idle"
|
||||||
| "loading"
|
| "loading"
|
||||||
@@ -46,55 +107,14 @@ export type RecordedPointColorLoadState = Pick<
|
|||||||
"phase" | "receivedBytes" | "totalBytes" | "progress" | "message"
|
"phase" | "receivedBytes" | "totalBytes" | "progress" | "message"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface RecordedPerceptionLayers {
|
|
||||||
enabled: boolean;
|
|
||||||
detections2d: boolean;
|
|
||||||
segmentation: boolean;
|
|
||||||
cuboids3d: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RerunSelection {
|
export interface RerunSelection {
|
||||||
entityPath: string;
|
entityPath: string;
|
||||||
viewName?: string;
|
viewName?: string;
|
||||||
position?: [number, number, number];
|
position?: [number, number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RerunPlaybackState {
|
|
||||||
recordingId: string;
|
|
||||||
timeline: string;
|
|
||||||
rangeNs: { min: number; max: number } | null;
|
|
||||||
currentNs: number;
|
|
||||||
playing: boolean;
|
|
||||||
/** Latest session-time value currently available to the browser receiver. */
|
|
||||||
bufferedEndNs: number | null;
|
|
||||||
/** Declared first session-time value, when the archive descriptor provides it. */
|
|
||||||
expectedStartNs: number | null;
|
|
||||||
/** Declared final session-time value, when the archive descriptor provides it. */
|
|
||||||
expectedEndNs: number | null;
|
|
||||||
/** Download progress in the closed interval 0..1, or null without a valid expectation. */
|
|
||||||
bufferProgress: number | null;
|
|
||||||
/** True only when the buffered range has reached the declared archive end. */
|
|
||||||
fullyBuffered: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RerunPlaybackController {
|
|
||||||
seek: (timeNs: number) => void;
|
|
||||||
setPlaying: (playing: boolean) => void;
|
|
||||||
jumpToEnd: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RerunViewportProps {
|
export interface RerunViewportProps {
|
||||||
sourceUrl: string;
|
profile: RerunViewerProfile;
|
||||||
recordedArtifact?: RecordedRrdArtifactDescriptor | null;
|
|
||||||
followLive?: boolean;
|
|
||||||
liveActivitySequence?: number | null;
|
|
||||||
liveStreamId?: string | null;
|
|
||||||
liveRecoveryAuthorityIdentity?: string | null;
|
|
||||||
autoplayWhenReady?: boolean;
|
|
||||||
presentationGate?: RecordedAdmissionPhase;
|
|
||||||
expectedTimelineStartSeconds?: number;
|
|
||||||
expectedTimelineEndSeconds?: number;
|
|
||||||
initialPlaybackStartSeconds?: number;
|
|
||||||
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
onStatusChange?: (status: RerunViewportStatus, message?: string) => void;
|
||||||
onSelectionChange?: (selection: RerunSelection | null) => void;
|
onSelectionChange?: (selection: RerunSelection | null) => void;
|
||||||
onPlaybackChange?: (state: RerunPlaybackState | null) => void;
|
onPlaybackChange?: (state: RerunPlaybackState | null) => void;
|
||||||
@@ -110,23 +130,10 @@ export interface RerunViewportProps {
|
|||||||
| "palette"
|
| "palette"
|
||||||
| "customColor"
|
| "customColor"
|
||||||
>;
|
>;
|
||||||
recordedView?: RecordedRerunView;
|
|
||||||
recordedViewResetGeneration?: 0 | 1;
|
|
||||||
recordedFollowTrajectory?: boolean;
|
|
||||||
recordedPerceptionLayers?: RecordedPerceptionLayers;
|
|
||||||
recordedPerceptionRetryGeneration?: number;
|
|
||||||
lockPerceptionCameraInteraction?: boolean;
|
|
||||||
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
|
onPerceptionLoadChange?: (state: RecordedPerceptionLoadState) => void;
|
||||||
onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void;
|
onPointColorLoadChange?: (state: RecordedPointColorLoadState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordedRrdArtifactDescriptor {
|
|
||||||
sourceUrl: string;
|
|
||||||
viewerSourceUrl: string;
|
|
||||||
byteLength: number;
|
|
||||||
sha256: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RerunBlueprintChannel {
|
interface RerunBlueprintChannel {
|
||||||
endpointUrl: string;
|
endpointUrl: string;
|
||||||
channel: {
|
channel: {
|
||||||
@@ -147,375 +154,6 @@ const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][
|
|||||||
const RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.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_BLUEPRINT_BYTES = 1_048_576;
|
||||||
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
|
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
|
||||||
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
|
||||||
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) {
|
|
||||||
throw new Error("Unsafe recorded RRD byte length");
|
|
||||||
}
|
|
||||||
const transferBudgetMs = Math.ceil(
|
|
||||||
(byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000,
|
|
||||||
);
|
|
||||||
return Math.min(
|
|
||||||
RECORDED_OPEN_MAX_TIMEOUT_MS,
|
|
||||||
Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRecordedOpenWatchdog<T>({
|
|
||||||
byteLength,
|
|
||||||
schedule,
|
|
||||||
cancel,
|
|
||||||
onTimeout,
|
|
||||||
}: {
|
|
||||||
byteLength: number;
|
|
||||||
schedule: (callback: () => void, timeoutMs: number) => T;
|
|
||||||
cancel: (handle: T) => void;
|
|
||||||
onTimeout: () => void;
|
|
||||||
}): { arm: () => void; clear: () => void; pending: () => boolean } {
|
|
||||||
const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength);
|
|
||||||
let handle: T | null = null;
|
|
||||||
return {
|
|
||||||
arm() {
|
|
||||||
if (handle !== null) return;
|
|
||||||
handle = schedule(() => {
|
|
||||||
handle = null;
|
|
||||||
onTimeout();
|
|
||||||
}, timeoutMs);
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
if (handle === null) return;
|
|
||||||
cancel(handle);
|
|
||||||
handle = null;
|
|
||||||
},
|
|
||||||
pending: () => handle !== null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createReentrantViewerDisposer(
|
|
||||||
cleanupOnce: () => void,
|
|
||||||
releaseNativeViewer: () => void,
|
|
||||||
): () => void {
|
|
||||||
let cleanupComplete = false;
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
if (!cleanupComplete) {
|
|
||||||
cleanupComplete = true;
|
|
||||||
cleanupOnce();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// `viewer.start()` can resolve after an earlier pre-ready stop. Reapply
|
|
||||||
// native release on every disposal boundary so that a stale viewer can
|
|
||||||
// never reopen after React and diagnostics have already unmounted it.
|
|
||||||
releaseNativeViewer();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveLiveViewerOwner {
|
|
||||||
release: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Own exactly one native live receiver per application document.
|
|
||||||
*
|
|
||||||
* React route/StrictMode transitions can overlap two mounted workspaces for a
|
|
||||||
* render turn. Rerun keeps each native gRPC receiver alive independently, so
|
|
||||||
* the overlap used to consume the bounded live replay slots and leave the
|
|
||||||
* operator's visible canvas black. Claiming the next owner synchronously
|
|
||||||
* retires the previous native receiver before the next one starts.
|
|
||||||
*/
|
|
||||||
export function claimExclusiveLiveViewer(release: () => void): () => void {
|
|
||||||
const owner = { release };
|
|
||||||
const previous = activeLiveViewerOwner;
|
|
||||||
activeLiveViewerOwner = owner;
|
|
||||||
previous?.release();
|
|
||||||
return () => {
|
|
||||||
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordedPlaybackBufferState {
|
|
||||||
bufferedEndNs: number | null;
|
|
||||||
expectedStartNs: number | null;
|
|
||||||
expectedEndNs: number | null;
|
|
||||||
bufferProgress: number | null;
|
|
||||||
fullyBuffered: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Rerun time range is usable as soon as it contains one finite timestamp.
|
|
||||||
* A first frame commonly has min === max; waiting for positive duration would
|
|
||||||
* needlessly keep that visible frame behind the loading screen.
|
|
||||||
*/
|
|
||||||
export function isUsableRecordedPlaybackRange(
|
|
||||||
rangeNs: { min: number; max: number } | null,
|
|
||||||
): rangeNs is { min: number; max: number } {
|
|
||||||
return Boolean(
|
|
||||||
rangeNs &&
|
|
||||||
Number.isFinite(rangeNs.min) &&
|
|
||||||
Number.isFinite(rangeNs.max) &&
|
|
||||||
rangeNs.max >= rangeNs.min,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A live receiver is presentable only after the exact browser store exposes
|
|
||||||
* real timeline data that the backend has also confirmed publishing.
|
|
||||||
* `WebViewer.start()` and a non-null active recording id are transport setup,
|
|
||||||
* not evidence that the spatial scene can render.
|
|
||||||
*/
|
|
||||||
export function isLiveRerunPresentationReady(
|
|
||||||
viewerStarted: boolean,
|
|
||||||
rangeNs: { min: number; max: number } | null,
|
|
||||||
backendActivitySequence: number | null,
|
|
||||||
): boolean {
|
|
||||||
return viewerStarted &&
|
|
||||||
Number.isSafeInteger(backendActivitySequence) &&
|
|
||||||
(backendActivitySequence ?? 0) > 0 &&
|
|
||||||
isUsableRecordedPlaybackRange(rangeNs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `recording_open` can arrive before Rerun has registered the live timeline.
|
|
||||||
* Selecting it at that point is a silent no-op, so keep retrying only until
|
|
||||||
* the exact live timeline is both available and active.
|
|
||||||
*/
|
|
||||||
export function liveTimelineNeedsSynchronization(
|
|
||||||
followLive: boolean,
|
|
||||||
activeTimeline: string | null | undefined,
|
|
||||||
rangeNs: { min: number; max: number } | null,
|
|
||||||
): boolean {
|
|
||||||
return followLive &&
|
|
||||||
isUsableRecordedPlaybackRange(rangeNs) &&
|
|
||||||
activeTimeline !== "stream_time";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Key the native receiver to its data-plane binding. Recovery authority is a
|
|
||||||
* retry fence projected from changing supervisor snapshots; it must not tear
|
|
||||||
* down a healthy WebViewer while this acquisition and URL remain unchanged.
|
|
||||||
*/
|
|
||||||
export function liveRerunReceiverBindingIdentity(
|
|
||||||
sourceUrl: string,
|
|
||||||
liveStreamId: string | null,
|
|
||||||
followLive: boolean,
|
|
||||||
): string {
|
|
||||||
return JSON.stringify([
|
|
||||||
followLive ? "live" : "recorded",
|
|
||||||
sourceUrl.trim(),
|
|
||||||
followLive ? liveStreamId?.trim() ?? "" : "",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Describe progressive archive availability without gating first rendering. */
|
|
||||||
export function recordedPlaybackBufferState(
|
|
||||||
rangeNs: { min: number; max: number } | null,
|
|
||||||
expectedTimelineEndSeconds?: number,
|
|
||||||
expectedTimelineStartSeconds?: number,
|
|
||||||
): RecordedPlaybackBufferState {
|
|
||||||
const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null;
|
|
||||||
const bufferedEndNs = usableRange?.max ?? null;
|
|
||||||
if (expectedTimelineEndSeconds === undefined) {
|
|
||||||
return {
|
|
||||||
bufferedEndNs,
|
|
||||||
expectedStartNs: null,
|
|
||||||
expectedEndNs: null,
|
|
||||||
bufferProgress: null,
|
|
||||||
fullyBuffered: usableRange !== null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) {
|
|
||||||
return {
|
|
||||||
bufferedEndNs,
|
|
||||||
expectedStartNs: null,
|
|
||||||
expectedEndNs: null,
|
|
||||||
bufferProgress: null,
|
|
||||||
fullyBuffered: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000;
|
|
||||||
const expectedStartNs = expectedTimelineStartSeconds === undefined
|
|
||||||
? 0
|
|
||||||
: expectedTimelineStartSeconds * 1_000_000_000;
|
|
||||||
if (
|
|
||||||
!Number.isFinite(expectedEndNs) ||
|
|
||||||
!Number.isFinite(expectedStartNs) ||
|
|
||||||
expectedStartNs < 0 ||
|
|
||||||
expectedEndNs < expectedStartNs
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
bufferedEndNs,
|
|
||||||
expectedStartNs: null,
|
|
||||||
expectedEndNs: null,
|
|
||||||
bufferProgress: null,
|
|
||||||
fullyBuffered: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Rerun split preserves whole boundary chunks so that all generated splits
|
|
||||||
// still sum exactly to the source recording. A verified bounded artifact can
|
|
||||||
// therefore begin slightly before its declared operator window. Admission
|
|
||||||
// requires coverage of the declared window, not byte-chunk boundary equality.
|
|
||||||
const fullyBuffered = usableRange !== null &&
|
|
||||||
usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS &&
|
|
||||||
usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS;
|
|
||||||
const expectedDurationNs = expectedEndNs - expectedStartNs;
|
|
||||||
const bufferProgress = bufferedEndNs === null
|
|
||||||
? 0
|
|
||||||
: expectedDurationNs <= 0
|
|
||||||
? (fullyBuffered ? 1 : 0)
|
|
||||||
: Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs));
|
|
||||||
return {
|
|
||||||
bufferedEndNs,
|
|
||||||
expectedStartNs,
|
|
||||||
expectedEndNs,
|
|
||||||
bufferProgress,
|
|
||||||
fullyBuffered,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A recorded viewport is publishable only after its declared range is complete. */
|
|
||||||
export function isRecordedPlaybackReady(
|
|
||||||
viewerStarted: boolean,
|
|
||||||
artifactVerified: boolean,
|
|
||||||
buffer: RecordedPlaybackBufferState,
|
|
||||||
): boolean {
|
|
||||||
return viewerStarted && artifactVerified && buffer.fullyBuffered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep the host scrubber and its controls disconnected from a partial archive. */
|
|
||||||
export function recordedPlaybackRangeWhenReady(
|
|
||||||
rangeNs: { min: number; max: number } | null,
|
|
||||||
buffer: RecordedPlaybackBufferState,
|
|
||||||
artifactVerified: boolean,
|
|
||||||
): { min: number; max: number } | null {
|
|
||||||
if (
|
|
||||||
!artifactVerified ||
|
|
||||||
!buffer.fullyBuffered ||
|
|
||||||
!isUsableRecordedPlaybackRange(rangeNs)
|
|
||||||
) return null;
|
|
||||||
return {
|
|
||||||
min: buffer.expectedStartNs === null
|
|
||||||
? rangeNs.min
|
|
||||||
: Math.max(rangeNs.min, buffer.expectedStartNs),
|
|
||||||
max: buffer.expectedEndNs === null
|
|
||||||
? rangeNs.max
|
|
||||||
: Math.min(rangeNs.max, buffer.expectedEndNs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Do not mount host timeline controls while a recorded generation is partial. */
|
|
||||||
export function isRecordedPlaybackPresentationReady(
|
|
||||||
status: RerunViewportStatus,
|
|
||||||
playback: RerunPlaybackState | null,
|
|
||||||
): boolean {
|
|
||||||
return status === "ready" &&
|
|
||||||
playback?.fullyBuffered === true &&
|
|
||||||
isUsableRecordedPlaybackRange(playback.rangeNs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rerunPresentationStatus(
|
|
||||||
status: RerunViewportStatus,
|
|
||||||
gate: RecordedAdmissionPhase,
|
|
||||||
recorded: boolean,
|
|
||||||
): RerunViewportStatus {
|
|
||||||
if (!recorded) return status;
|
|
||||||
if (status === "error" || gate === "error") return "error";
|
|
||||||
if (gate !== "ready") return "loading";
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRecordedPlaybackFullyBuffered(
|
|
||||||
rangeNs: { min: number; max: number } | null,
|
|
||||||
expectedTimelineEndSeconds?: number,
|
|
||||||
): boolean {
|
|
||||||
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attemptRecordedAutoplay(
|
|
||||||
seekToStart: () => void,
|
|
||||||
startPlaying: () => void,
|
|
||||||
): boolean {
|
|
||||||
try {
|
|
||||||
seekToStart();
|
|
||||||
startPlaying();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Admit autoplay once, after the viewer and the complete recorded range are
|
|
||||||
* ready. The attempt is consumed even if a vendor call throws: subsequent
|
|
||||||
* polling must never seek the operator back to the beginning a second time.
|
|
||||||
*/
|
|
||||||
export function createRecordedAutoplayGate(): {
|
|
||||||
attempt: (
|
|
||||||
viewerStarted: boolean,
|
|
||||||
fullyBuffered: boolean,
|
|
||||||
presentationReady: boolean,
|
|
||||||
rangeNs: { min: number; max: number } | null,
|
|
||||||
seekToStart: (startNs: number) => void,
|
|
||||||
startPlaying: () => void,
|
|
||||||
preferredStartNs?: number,
|
|
||||||
) => boolean;
|
|
||||||
attempted: () => boolean;
|
|
||||||
} {
|
|
||||||
let consumed = false;
|
|
||||||
return {
|
|
||||||
attempt(
|
|
||||||
viewerStarted,
|
|
||||||
fullyBuffered,
|
|
||||||
presentationReady,
|
|
||||||
rangeNs,
|
|
||||||
seekToStart,
|
|
||||||
startPlaying,
|
|
||||||
preferredStartNs,
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
consumed ||
|
|
||||||
!viewerStarted ||
|
|
||||||
!fullyBuffered ||
|
|
||||||
!presentationReady ||
|
|
||||||
!isUsableRecordedPlaybackRange(rangeNs)
|
|
||||||
) return false;
|
|
||||||
consumed = true;
|
|
||||||
const startNs = Number.isFinite(preferredStartNs)
|
|
||||||
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
|
|
||||||
: rangeNs.min;
|
|
||||||
return attemptRecordedAutoplay(
|
|
||||||
() => seekToStart(startNs),
|
|
||||||
startPlaying,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
attempted: () => consumed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function canPublishRecordedPlaybackController(
|
|
||||||
readyToRender: boolean,
|
|
||||||
presentationGate: RecordedAdmissionPhase,
|
|
||||||
): boolean {
|
|
||||||
return readyToRender && presentationGate === "ready";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLatestAnimationFrameEmitter<T>({
|
export function createLatestAnimationFrameEmitter<T>({
|
||||||
emit,
|
emit,
|
||||||
@@ -917,36 +555,43 @@ export async function fetchRecordedPerceptionRrd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RerunViewport({
|
export function RerunViewport({
|
||||||
sourceUrl,
|
profile,
|
||||||
recordedArtifact = null,
|
|
||||||
followLive = false,
|
|
||||||
liveActivitySequence = null,
|
|
||||||
liveStreamId = null,
|
|
||||||
liveRecoveryAuthorityIdentity = null,
|
|
||||||
autoplayWhenReady = false,
|
|
||||||
presentationGate = "ready",
|
|
||||||
expectedTimelineStartSeconds,
|
|
||||||
expectedTimelineEndSeconds,
|
|
||||||
initialPlaybackStartSeconds,
|
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
onPlaybackControllerChange,
|
onPlaybackControllerChange,
|
||||||
sceneSettings,
|
sceneSettings,
|
||||||
recordedView = "spatial",
|
onPerceptionLoadChange,
|
||||||
recordedViewResetGeneration = 0,
|
onPointColorLoadChange,
|
||||||
recordedFollowTrajectory = false,
|
}: RerunViewportProps) {
|
||||||
recordedPerceptionLayers = {
|
const recordedProfile = profile.kind === "recorded-session" ? profile : null;
|
||||||
|
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
||||||
|
const sourceUrl = profile.sourceUrl;
|
||||||
|
const recordedArtifact = recordedProfile?.artifact ?? null;
|
||||||
|
const followLive = liveProfile !== null;
|
||||||
|
const liveActivitySequence = liveProfile?.liveActivitySequence ?? null;
|
||||||
|
const liveStreamId = liveProfile?.liveStreamId ?? null;
|
||||||
|
const liveRecoveryAuthorityIdentity =
|
||||||
|
liveProfile?.liveRecoveryAuthorityIdentity ?? null;
|
||||||
|
const autoplayWhenReady = recordedProfile?.autoplayWhenReady ?? false;
|
||||||
|
const presentationGate = recordedProfile?.presentationGate ?? "ready";
|
||||||
|
const expectedTimelineStartSeconds =
|
||||||
|
recordedProfile?.expectedTimelineStartSeconds;
|
||||||
|
const expectedTimelineEndSeconds = recordedProfile?.expectedTimelineEndSeconds;
|
||||||
|
const initialPlaybackStartSeconds = recordedProfile?.initialPlaybackStartSeconds;
|
||||||
|
const recordedView = recordedProfile?.view ?? "spatial";
|
||||||
|
const recordedViewResetGeneration = recordedProfile?.viewResetGeneration ?? 0;
|
||||||
|
const recordedFollowTrajectory = recordedProfile?.followTrajectory ?? false;
|
||||||
|
const recordedPerceptionLayers = recordedProfile?.perceptionLayers ?? {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
detections2d: false,
|
detections2d: false,
|
||||||
segmentation: false,
|
segmentation: false,
|
||||||
cuboids3d: false,
|
cuboids3d: false,
|
||||||
},
|
};
|
||||||
recordedPerceptionRetryGeneration = 0,
|
const recordedPerceptionRetryGeneration =
|
||||||
lockPerceptionCameraInteraction = false,
|
recordedProfile?.perceptionRetryGeneration ?? 0;
|
||||||
onPerceptionLoadChange,
|
const lockPerceptionCameraInteraction =
|
||||||
onPointColorLoadChange,
|
recordedProfile?.lockPerceptionCameraInteraction ?? false;
|
||||||
}: 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);
|
||||||
@@ -1588,7 +1233,7 @@ export function RerunViewport({
|
|||||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||||
}
|
}
|
||||||
if (!followLive) clearRecordedAdmissionWatchdog();
|
if (!followLive) clearRecordedAdmissionWatchdog();
|
||||||
if (!followLive) setRecordingBufferProgress(1);
|
if (!followLive) setRecordingBufferProgress(recordedBuffer.bufferProgress);
|
||||||
recordedSceneAdmitted = true;
|
recordedSceneAdmitted = true;
|
||||||
if (!followLive) onPlaybackChange?.(playbackState);
|
if (!followLive) onPlaybackChange?.(playbackState);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
@@ -1597,7 +1242,10 @@ export function RerunViewport({
|
|||||||
if (
|
if (
|
||||||
!followLive &&
|
!followLive &&
|
||||||
!playbackControllerPublished &&
|
!playbackControllerPublished &&
|
||||||
canPublishRecordedPlaybackController(readyToRender, presentationGateRef.current)
|
canPublishRecordedPlaybackController(
|
||||||
|
recordedBuffer.fullyBuffered,
|
||||||
|
presentationGateRef.current,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
playbackControllerPublished = true;
|
playbackControllerPublished = true;
|
||||||
onPlaybackControllerChange?.(playbackController);
|
onPlaybackControllerChange?.(playbackController);
|
||||||
@@ -1740,9 +1388,16 @@ export function RerunViewport({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(async () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposeViewer?.();
|
disposeViewer?.();
|
||||||
|
if (
|
||||||
|
isRecordedSource &&
|
||||||
|
await reloadRecordedViewerAfterStaleModuleFailure({
|
||||||
|
loadedUiBuildId: diagnosticLifecycle.lineage.uiBuildId,
|
||||||
|
signal: diagnosticLifecycle.signal,
|
||||||
|
})
|
||||||
|
) return;
|
||||||
if (requestLiveRecovery("module-load")) return;
|
if (requestLiveRecovery("module-load")) return;
|
||||||
reportError("Не удалось загрузить модуль визуализатора.");
|
reportError("Не удалось загрузить модуль визуализатора.");
|
||||||
});
|
});
|
||||||
@@ -1786,7 +1441,18 @@ export function RerunViewport({
|
|||||||
}, [recordedPointColorsUrl]);
|
}, [recordedPointColorsUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recordedPerceptionUrl) return;
|
if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) {
|
||||||
|
if (!recordedPerceptionLayers.enabled) {
|
||||||
|
onPerceptionLoadChange?.({
|
||||||
|
phase: "idle",
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
progress: null,
|
||||||
|
message: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const active = perceptionChannelRef.current;
|
const active = perceptionChannelRef.current;
|
||||||
const identity = recordedIdentityRef.current;
|
const identity = recordedIdentityRef.current;
|
||||||
if (
|
if (
|
||||||
@@ -1931,6 +1597,7 @@ export function RerunViewport({
|
|||||||
}, [
|
}, [
|
||||||
onPerceptionLoadChange,
|
onPerceptionLoadChange,
|
||||||
perceptionChannelRevision,
|
perceptionChannelRevision,
|
||||||
|
recordedPerceptionLayers.enabled,
|
||||||
recordedPerceptionRetryGeneration,
|
recordedPerceptionRetryGeneration,
|
||||||
recordedPerceptionUrl,
|
recordedPerceptionUrl,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { SegmentedControl, SplitPane, type SplitPaneOrientation } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import { LaboratoryEvidenceViewer } from "./LaboratoryEvidenceViewer";
|
||||||
|
|
||||||
|
export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
|
||||||
|
"missioncore.canonical-recorded-lab-replay/v1";
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabMode<T extends string> {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interaction contract from the accepted recorded-LAB instrument.
|
||||||
|
*
|
||||||
|
* Keeping pane selection, collapse semantics, responsive split orientation,
|
||||||
|
* expansion and splitter state here prevents individual experiments from
|
||||||
|
* quietly growing their own replay behaviour. Experiments provide evidence
|
||||||
|
* layers; they do not reimplement the laboratory shell.
|
||||||
|
*/
|
||||||
|
export function useCanonicalRecordedLabReplayState<
|
||||||
|
TMedia extends string,
|
||||||
|
TSpatial extends string,
|
||||||
|
>({
|
||||||
|
initialMediaMode,
|
||||||
|
initialSpatialMode,
|
||||||
|
}: {
|
||||||
|
initialMediaMode: TMedia;
|
||||||
|
initialSpatialMode: TSpatial | null;
|
||||||
|
}) {
|
||||||
|
const [mediaMode, setMediaMode] = useState<TMedia | null>(initialMediaMode);
|
||||||
|
const [spatialMode, setSpatialMode] = useState<TSpatial | null>(initialSpatialMode);
|
||||||
|
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
||||||
|
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
||||||
|
? "horizontal"
|
||||||
|
: "vertical"
|
||||||
|
));
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = window.matchMedia("(max-width: 900px)");
|
||||||
|
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
||||||
|
update();
|
||||||
|
query.addEventListener("change", update);
|
||||||
|
return () => query.removeEventListener("change", update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onMediaModeChange = useCallback((next: TMedia | "none") => {
|
||||||
|
if (next === "none") return;
|
||||||
|
setMediaMode((current) => current === next ? null : next);
|
||||||
|
}, []);
|
||||||
|
const onSpatialModeChange = useCallback((next: TSpatial | "none") => {
|
||||||
|
if (next === "none") return;
|
||||||
|
setSpatialMode((current) => current === next ? null : next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
splitView: mediaMode !== null && spatialMode !== null,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
expanded,
|
||||||
|
onMediaModeChange,
|
||||||
|
onSpatialModeChange,
|
||||||
|
onSplitPrimarySizeChange: setSplitPrimarySize,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanonicalRecordedLabReplay<
|
||||||
|
TMedia extends string,
|
||||||
|
TSpatial extends string,
|
||||||
|
>({
|
||||||
|
label,
|
||||||
|
mediaMode,
|
||||||
|
mediaModes,
|
||||||
|
spatialMode,
|
||||||
|
spatialModes,
|
||||||
|
expanded,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
mediaAriaLabel,
|
||||||
|
spatialAriaLabel,
|
||||||
|
mediaLayerControls,
|
||||||
|
spatialLayerControls,
|
||||||
|
spatialLeadingControl,
|
||||||
|
mediaMultiLayer = false,
|
||||||
|
mediaContent,
|
||||||
|
spatialContent,
|
||||||
|
emptyMessage,
|
||||||
|
deckOverlays,
|
||||||
|
actions,
|
||||||
|
overlay,
|
||||||
|
transport,
|
||||||
|
trailingActions,
|
||||||
|
onMediaModeChange,
|
||||||
|
onSpatialModeChange,
|
||||||
|
onExpandedChange,
|
||||||
|
onSplitPrimarySizeChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
mediaMode: TMedia;
|
||||||
|
mediaModes: readonly CanonicalRecordedLabMode<TMedia>[];
|
||||||
|
spatialMode: TSpatial;
|
||||||
|
spatialModes: readonly CanonicalRecordedLabMode<TSpatial>[];
|
||||||
|
expanded: boolean;
|
||||||
|
splitPrimarySize: number;
|
||||||
|
splitOrientation: SplitPaneOrientation;
|
||||||
|
mediaAriaLabel: string;
|
||||||
|
spatialAriaLabel: string;
|
||||||
|
mediaLayerControls?: ReactNode;
|
||||||
|
spatialLayerControls?: ReactNode;
|
||||||
|
spatialLeadingControl?: ReactNode;
|
||||||
|
mediaMultiLayer?: boolean;
|
||||||
|
mediaContent: ReactNode;
|
||||||
|
spatialContent: ReactNode;
|
||||||
|
emptyMessage: string;
|
||||||
|
deckOverlays?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
overlay?: ReactNode;
|
||||||
|
transport?: ReactNode;
|
||||||
|
trailingActions?: ReactNode;
|
||||||
|
onMediaModeChange: (mode: TMedia) => void;
|
||||||
|
onSpatialModeChange: (mode: TSpatial) => void;
|
||||||
|
onExpandedChange: (expanded: boolean) => void;
|
||||||
|
onSplitPrimarySizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const splitView = mediaMode !== "none" && spatialMode !== "none";
|
||||||
|
const mediaModeControls = (
|
||||||
|
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
||||||
|
<SegmentedControl
|
||||||
|
value={mediaMode}
|
||||||
|
items={[...mediaModes]}
|
||||||
|
label="Видео и камера"
|
||||||
|
onChange={onMediaModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const spatialModeControls = (
|
||||||
|
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
||||||
|
<SegmentedControl
|
||||||
|
value={spatialMode}
|
||||||
|
items={[...spatialModes]}
|
||||||
|
label="3D и план"
|
||||||
|
onChange={onSpatialModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const mediaPane = (
|
||||||
|
<section
|
||||||
|
className="m4-replay-threat-visual__pane"
|
||||||
|
data-pane="media"
|
||||||
|
aria-label={mediaAriaLabel}
|
||||||
|
hidden={mediaMode === "none"}
|
||||||
|
>
|
||||||
|
{splitView ? (
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__pane-toolbar"
|
||||||
|
data-pane-toolbar="media"
|
||||||
|
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
|
||||||
|
>
|
||||||
|
{mediaLayerControls}
|
||||||
|
{mediaModeControls}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{mediaContent}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
const spatialPane = spatialMode !== "none" ? (
|
||||||
|
<section
|
||||||
|
className="m4-replay-threat-visual__pane"
|
||||||
|
data-pane="spatial"
|
||||||
|
aria-label={spatialAriaLabel}
|
||||||
|
>
|
||||||
|
{splitView ? (
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__pane-toolbar"
|
||||||
|
data-pane-toolbar="spatial"
|
||||||
|
>
|
||||||
|
{spatialLeadingControl}
|
||||||
|
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||||
|
{spatialLayerControls}
|
||||||
|
{spatialModeControls}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{spatialContent}
|
||||||
|
</section>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="l3-visual-audit m4-replay-threat-visual"
|
||||||
|
data-contract={CANONICAL_RECORDED_LAB_REPLAY_CONTRACT}
|
||||||
|
>
|
||||||
|
<LaboratoryEvidenceViewer
|
||||||
|
label={label}
|
||||||
|
className="m4-replay-threat-evidence-viewer"
|
||||||
|
mode={mediaMode}
|
||||||
|
modes={[...mediaModes]}
|
||||||
|
secondaryMode={{
|
||||||
|
value: spatialMode,
|
||||||
|
modes: [...spatialModes],
|
||||||
|
label: "3D и план",
|
||||||
|
onChange: onSpatialModeChange,
|
||||||
|
}}
|
||||||
|
expanded={expanded}
|
||||||
|
onModeChange={onMediaModeChange}
|
||||||
|
onExpandedChange={onExpandedChange}
|
||||||
|
modeControlsVisible={!splitView}
|
||||||
|
actions={actions}
|
||||||
|
overlay={overlay}
|
||||||
|
transport={transport}
|
||||||
|
trailingActions={trailingActions}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="m4-replay-threat-visual__deck"
|
||||||
|
data-split={splitView ? "true" : undefined}
|
||||||
|
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
|
||||||
|
>
|
||||||
|
<SplitPane
|
||||||
|
primary={mediaPane}
|
||||||
|
secondary={spatialPane ?? <div />}
|
||||||
|
primarySize={splitView ? splitPrimarySize : mediaMode !== "none" ? 100 : 0}
|
||||||
|
onPrimarySizeChange={onSplitPrimarySizeChange}
|
||||||
|
orientation={splitOrientation}
|
||||||
|
minPrimarySize={splitView ? 24 : 0}
|
||||||
|
minSecondarySize={splitView ? 24 : 0}
|
||||||
|
resizable={splitView}
|
||||||
|
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
|
||||||
|
/>
|
||||||
|
{mediaMode === "none" && spatialMode === "none" ? (
|
||||||
|
<div className="l3-visual-audit__state" role="status">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{deckOverlays}
|
||||||
|
</div>
|
||||||
|
</LaboratoryEvidenceViewer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,16 @@ export function laboratoryRecordedClipEndExclusiveNs(
|
|||||||
return last.sourceTimeNs + typicalDelta;
|
return last.sourceTimeNs + typicalDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function laboratoryRecordedClipClockGate(
|
||||||
|
pendingSequence: number | null,
|
||||||
|
observedSequence: number,
|
||||||
|
): { accept: boolean; pendingSequence: number | null } {
|
||||||
|
if (pendingSequence !== null && pendingSequence !== observedSequence) {
|
||||||
|
return { accept: false, pendingSequence };
|
||||||
|
}
|
||||||
|
return { accept: true, pendingSequence: null };
|
||||||
|
}
|
||||||
|
|
||||||
export function LaboratoryRecordedClipPlayer({
|
export function LaboratoryRecordedClipPlayer({
|
||||||
source,
|
source,
|
||||||
segmentCount,
|
segmentCount,
|
||||||
@@ -94,7 +104,8 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
}) {
|
}) {
|
||||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||||
const lastEmittedSequenceRef = useRef(sequence);
|
const lastEmittedSequenceRef = useRef(sequence);
|
||||||
lastEmittedSequenceRef.current = sequence;
|
const lastObservedSequenceRef = useRef<number | null>(sequence);
|
||||||
|
const pendingSequenceRef = useRef<number | null>(null);
|
||||||
const frame = useMemo(
|
const frame = useMemo(
|
||||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||||
[frames, sequence],
|
[frames, sequence],
|
||||||
@@ -113,23 +124,42 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||||
}, [continuousPlayback, onPlayingChange, playing]);
|
}, [continuousPlayback, onPlayingChange, playing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastObservedSequenceRef.current !== sequence) {
|
||||||
|
pendingSequenceRef.current = sequence;
|
||||||
|
}
|
||||||
|
lastEmittedSequenceRef.current = sequence;
|
||||||
|
}, [sequence]);
|
||||||
|
|
||||||
const emitSequence = useCallback((nextSequence: number) => {
|
const emitSequence = useCallback((nextSequence: number) => {
|
||||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||||
lastEmittedSequenceRef.current = nextSequence;
|
lastEmittedSequenceRef.current = nextSequence;
|
||||||
onSequenceChange(nextSequence);
|
onSequenceChange(nextSequence);
|
||||||
}, [onSequenceChange]);
|
}, [onSequenceChange]);
|
||||||
|
|
||||||
|
const requestSequence = useCallback((nextSequence: number) => {
|
||||||
|
pendingSequenceRef.current = nextSequence;
|
||||||
|
emitSequence(nextSequence);
|
||||||
|
}, [emitSequence]);
|
||||||
|
|
||||||
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
||||||
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
||||||
const first = frames[0];
|
const first = frames[0];
|
||||||
if (!first || endExclusiveNs === null) return;
|
if (!first || endExclusiveNs === null) return;
|
||||||
if (sourceTimeNs >= endExclusiveNs) {
|
if (sourceTimeNs >= endExclusiveNs) {
|
||||||
emitSequence(first.sequence);
|
requestSequence(first.sequence);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||||
if (nearest) emitSequence(nearest.sequence);
|
if (!nearest) return;
|
||||||
}, [emitSequence, endExclusiveNs, frames]);
|
lastObservedSequenceRef.current = nearest.sequence;
|
||||||
|
const gate = laboratoryRecordedClipClockGate(
|
||||||
|
pendingSequenceRef.current,
|
||||||
|
nearest.sequence,
|
||||||
|
);
|
||||||
|
pendingSequenceRef.current = gate.pendingSequence;
|
||||||
|
if (gate.accept) emitSequence(nearest.sequence);
|
||||||
|
}, [emitSequence, endExclusiveNs, frames, requestSequence]);
|
||||||
|
|
||||||
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
||||||
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||||
@@ -142,7 +172,7 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
className="laboratory-recorded-clip-player__spatial"
|
className="laboratory-recorded-clip-player__spatial"
|
||||||
aria-hidden={cameraPresentation === "primary"}
|
aria-hidden={cameraPresentation === "primary"}
|
||||||
>
|
>
|
||||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
{alternativeScene}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const cameraPane = (
|
const cameraPane = (
|
||||||
@@ -202,7 +232,7 @@ export function LaboratoryRecordedClipPlayer({
|
|||||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||||
onSeek={(timeNs) => {
|
onSeek={(timeNs) => {
|
||||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||||
if (nearest) emitSequence(nearest.sequence);
|
if (nearest) requestSequence(nearest.sequence);
|
||||||
}}
|
}}
|
||||||
showJumpToEnd={false}
|
showJumpToEnd={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RecordedFmp4Player,
|
RecordedFmp4Player,
|
||||||
type RecordedObservationPlayback,
|
type RecordedObservationPlayback,
|
||||||
} from "../RecordedFmp4Player";
|
} from "../RecordedFmp4Player";
|
||||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||||
|
import type { RecordedCameraAdmissionState } from "../../core/observation/recordedSessionAdmission";
|
||||||
import {
|
import {
|
||||||
RecordedEvidenceBoxOverlay,
|
RecordedEvidenceBoxOverlay,
|
||||||
type RecordedEvidenceBox,
|
type RecordedEvidenceBox,
|
||||||
@@ -25,6 +28,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
boxes,
|
boxes,
|
||||||
|
overlaySeconds,
|
||||||
semanticOverlay,
|
semanticOverlay,
|
||||||
pointCloudOverlay,
|
pointCloudOverlay,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
@@ -33,12 +37,17 @@ export function RecordedEvidenceVideoScene({
|
|||||||
segmentCount,
|
segmentCount,
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
onPlayingRejected,
|
onPlayingRejected,
|
||||||
|
onAdmissionChange,
|
||||||
|
playbackAuthority = "media",
|
||||||
|
playbackTransport = "segmented",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
}: {
|
}: {
|
||||||
source: ObservationSourceDescriptor;
|
source: ObservationSourceDescriptor;
|
||||||
playback: RecordedObservationPlayback;
|
playback: RecordedObservationPlayback;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
boxes: readonly RecordedEvidenceBox[];
|
boxes: readonly RecordedEvidenceBox[];
|
||||||
|
overlaySeconds?: number;
|
||||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||||
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
@@ -47,7 +56,41 @@ export function RecordedEvidenceVideoScene({
|
|||||||
segmentCount?: number;
|
segmentCount?: number;
|
||||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||||
onPlayingRejected?: () => void;
|
onPlayingRejected?: () => void;
|
||||||
|
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||||
|
playbackAuthority?: "media" | "host";
|
||||||
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const generation = source.delivery?.kind === "recorded-fmp4-manifest"
|
||||||
|
? source.delivery.manifestGenerationSha256
|
||||||
|
: "invalid";
|
||||||
|
const [admissionPhase, setAdmissionPhase] = useState<RecordedCameraAdmissionState["phase"]>(
|
||||||
|
"loading",
|
||||||
|
);
|
||||||
|
const [presentedSeconds, setPresentedSeconds] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setAdmissionPhase("loading");
|
||||||
|
setPresentedSeconds(null);
|
||||||
|
}, [generation, source.id]);
|
||||||
|
const handleAdmissionChange = (next: RecordedCameraAdmissionState) => {
|
||||||
|
setAdmissionPhase(next.phase);
|
||||||
|
onAdmissionChange?.(next);
|
||||||
|
};
|
||||||
|
const sourceReady = admissionPhase === "ready";
|
||||||
|
const overlaysPresented = sourceReady
|
||||||
|
&& presentedSeconds !== null
|
||||||
|
&& Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25
|
||||||
|
&& (overlaySeconds === undefined || Math.abs(presentedSeconds - overlaySeconds) <= 0.25);
|
||||||
|
const handlePlaybackChange = (next: RecordedObservationPlayback) => {
|
||||||
|
setPresentedSeconds(next.currentSeconds);
|
||||||
|
// During a paused operator seek the existing media element can emit its old
|
||||||
|
// timestamp while the requested MSE window is being rebuilt. That stale
|
||||||
|
// callback must not undo the host target before the decoder reaches it.
|
||||||
|
if (!playback.playing && Math.abs(next.currentSeconds - playback.currentSeconds) > 0.35) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPlaybackChange?.(next);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="recorded-evidence-video-scene">
|
<div className="recorded-evidence-video-scene">
|
||||||
<RecordedFmp4Player
|
<RecordedFmp4Player
|
||||||
@@ -57,29 +100,35 @@ export function RecordedEvidenceVideoScene({
|
|||||||
prepare
|
prepare
|
||||||
segmentSequence={segmentSequence}
|
segmentSequence={segmentSequence}
|
||||||
segmentCount={segmentCount}
|
segmentCount={segmentCount}
|
||||||
onPlaybackChange={onPlaybackChange}
|
onPlaybackChange={handlePlaybackChange}
|
||||||
onPlayingRejected={onPlayingRejected}
|
onPlayingRejected={onPlayingRejected}
|
||||||
|
onAdmissionChange={handleAdmissionChange}
|
||||||
|
playbackAuthority={playbackAuthority}
|
||||||
|
playbackTransport={playbackTransport}
|
||||||
|
recoverTimestampStalls={recoverTimestampStalls}
|
||||||
/>
|
/>
|
||||||
{semanticOverlay ? (
|
{overlaysPresented && semanticOverlay ? (
|
||||||
<RecordedEvidenceSemanticMaskOverlay
|
<RecordedEvidenceSemanticMaskOverlay
|
||||||
{...semanticOverlay}
|
{...semanticOverlay}
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{pointCloudOverlay ? (
|
{overlaysPresented && pointCloudOverlay ? (
|
||||||
<RecordedEvidencePointCloudOverlay
|
<RecordedEvidencePointCloudOverlay
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
overlay={pointCloudOverlay}
|
overlay={pointCloudOverlay}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{overlaysPresented ? (
|
||||||
<RecordedEvidenceBoxOverlay
|
<RecordedEvidenceBoxOverlay
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
boxes={boxes}
|
boxes={boxes}
|
||||||
ariaLabel={ariaLabel}
|
ariaLabel={ariaLabel}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchCanonicalRecordedLabSpatialFrame,
|
||||||
|
type CanonicalRecordedLabSpatialFrame,
|
||||||
|
} from "../../core/laboratory/canonicalRecordedLabSpatial";
|
||||||
|
|
||||||
|
const FRAME_CACHE_LIMIT = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared latest-request-wins scheduler for recorded LAB spatial evidence.
|
||||||
|
*
|
||||||
|
* A feature supplies only the sealed session identity and host-clock time.
|
||||||
|
* Cache ownership, identity fencing and stale-response suppression remain in
|
||||||
|
* the canonical instrument instead of being reimplemented per experiment.
|
||||||
|
*/
|
||||||
|
export function useCanonicalRecordedLabSpatialFrame({
|
||||||
|
sessionId,
|
||||||
|
generationSha256,
|
||||||
|
targetTimeNs,
|
||||||
|
}: {
|
||||||
|
sessionId: string;
|
||||||
|
generationSha256: string | null;
|
||||||
|
targetTimeNs: number;
|
||||||
|
}) {
|
||||||
|
const [frame, setFrame] = useState<CanonicalRecordedLabSpatialFrame | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const desiredRef = useRef<number | null>(null);
|
||||||
|
const runningRef = useRef(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const identityRef = useRef("");
|
||||||
|
const cacheRef = useRef(new Map<number, CanonicalRecordedLabSpatialFrame>());
|
||||||
|
const pumpRef = useRef<() => void>(() => undefined);
|
||||||
|
const identity = `${sessionId}:${generationSha256 ?? "unavailable"}`;
|
||||||
|
identityRef.current = identity;
|
||||||
|
|
||||||
|
pumpRef.current = () => {
|
||||||
|
if (runningRef.current || desiredRef.current === null || !generationSha256) return;
|
||||||
|
runningRef.current = true;
|
||||||
|
const requestIdentity = identity;
|
||||||
|
let settledTimeNs: number | null = null;
|
||||||
|
void (async () => {
|
||||||
|
while (
|
||||||
|
mountedRef.current
|
||||||
|
&& identityRef.current === requestIdentity
|
||||||
|
&& desiredRef.current !== null
|
||||||
|
) {
|
||||||
|
const requestedTimeNs = desiredRef.current;
|
||||||
|
const cached = cacheRef.current.get(requestedTimeNs);
|
||||||
|
try {
|
||||||
|
const next = cached ?? await fetchCanonicalRecordedLabSpatialFrame(
|
||||||
|
sessionId,
|
||||||
|
generationSha256,
|
||||||
|
requestedTimeNs,
|
||||||
|
);
|
||||||
|
if (!mountedRef.current || identityRef.current !== requestIdentity) break;
|
||||||
|
if (!cached) {
|
||||||
|
cacheRef.current.set(requestedTimeNs, next);
|
||||||
|
while (cacheRef.current.size > FRAME_CACHE_LIMIT) {
|
||||||
|
const oldest = cacheRef.current.keys().next().value as number | undefined;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
cacheRef.current.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFrame(next);
|
||||||
|
setError(null);
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
if (!mountedRef.current || identityRef.current !== requestIdentity) break;
|
||||||
|
setError(caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: "Spatial-слои записанной LAB недоступны.");
|
||||||
|
}
|
||||||
|
settledTimeNs = requestedTimeNs;
|
||||||
|
if (desiredRef.current === requestedTimeNs) break;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
runningRef.current = false;
|
||||||
|
if (
|
||||||
|
mountedRef.current
|
||||||
|
&& desiredRef.current !== null
|
||||||
|
&& (identityRef.current !== requestIdentity || desiredRef.current !== settledTimeNs)
|
||||||
|
) {
|
||||||
|
pumpRef.current();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
desiredRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cacheRef.current.clear();
|
||||||
|
desiredRef.current = null;
|
||||||
|
setFrame(null);
|
||||||
|
setError(null);
|
||||||
|
}, [identity]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!generationSha256) return;
|
||||||
|
desiredRef.current = targetTimeNs;
|
||||||
|
const cached = cacheRef.current.get(targetTimeNs);
|
||||||
|
if (cached) {
|
||||||
|
setFrame(cached);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pumpRef.current();
|
||||||
|
}, [generationSha256, identity, targetTimeNs]);
|
||||||
|
|
||||||
|
return { frame, error, loading: Boolean(generationSha256) && !frame && !error };
|
||||||
|
}
|
||||||
@@ -130,8 +130,12 @@ export function useRecordedEvidencePlayback(
|
|||||||
|
|
||||||
const synchronize = useCallback((next: RecordedObservationPlayback) => {
|
const synchronize = useCallback((next: RecordedObservationPlayback) => {
|
||||||
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
|
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
|
||||||
|
// Animation-clock mode is retained only for non-media diagnostics. A
|
||||||
|
// recorded LAB with video uses the external media clock so spatial and
|
||||||
|
// overlays never advance past the frame the decoder actually presented.
|
||||||
|
if (clock === "animation") return;
|
||||||
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
|
setPlayback((current) => synchronizeRecordedEvidencePlayback(current, next, range));
|
||||||
}, [range]);
|
}, [clock, range]);
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
playback,
|
playback,
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
private renderIntervalMs = 1000 / 60;
|
private renderIntervalMs = 1000 / 60;
|
||||||
private renderAccumulatorMs = 0;
|
private renderAccumulatorMs = 0;
|
||||||
private disposed = false;
|
private disposed = false;
|
||||||
|
private requestGsplatFrame = (): void => {
|
||||||
|
this.requestRender();
|
||||||
|
};
|
||||||
private updateFrame = (deltaSeconds: number): void => {
|
private updateFrame = (deltaSeconds: number): void => {
|
||||||
if (!this.app || !this.camera || this.disposed) return;
|
if (!this.app || !this.camera || this.disposed) return;
|
||||||
this.ugvController?.update(deltaSeconds);
|
this.ugvController?.update(deltaSeconds);
|
||||||
@@ -158,6 +161,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.app = app;
|
this.app = app;
|
||||||
app.autoRender = false;
|
app.autoRender = false;
|
||||||
app.on("update", this.updateFrame);
|
app.on("update", this.updateFrame);
|
||||||
|
app.systems.gsplat?.on("frame:request", this.requestGsplatFrame);
|
||||||
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||||
app.setCanvasResolution(RESOLUTION_AUTO);
|
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||||
@@ -379,6 +383,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
if (this.app) {
|
if (this.app) {
|
||||||
this.app.autoRender = false;
|
this.app.autoRender = false;
|
||||||
this.app.renderNextFrame = false;
|
this.app.renderNextFrame = false;
|
||||||
|
this.app.systems.gsplat?.off("frame:request", this.requestGsplatFrame);
|
||||||
this.app.off("update", this.updateFrame);
|
this.app.off("update", this.updateFrame);
|
||||||
this.app.destroy();
|
this.app.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ const PHYSICS_PROXY_ERROR = 0.0003;
|
|||||||
const PHYSICS_PROXY_MAX_TRIANGLES = 240_000;
|
const PHYSICS_PROXY_MAX_TRIANGLES = 240_000;
|
||||||
const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8;
|
const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8;
|
||||||
const COAST_DECELERATION_MPS2 = 0.18;
|
const COAST_DECELERATION_MPS2 = 0.18;
|
||||||
const BRAKE_ATTITUDE_DAMPING = 6;
|
const PARKING_BRAKE_HOLD_DECELERATION_MPS2 = 6;
|
||||||
|
const PARKING_BRAKE_ENGAGE_SPEED_MPS = 0.08;
|
||||||
|
const TYRE_FRICTION_SLIP = 8.5;
|
||||||
|
const TYRE_STATIC_FRICTION_COEFFICIENT = 0.95;
|
||||||
|
const TYRE_KINETIC_FRICTION_COEFFICIENT = 0.78;
|
||||||
|
const TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND = 10;
|
||||||
|
const GRAVITY_METERS_PER_SECOND_SQUARED = 9.81;
|
||||||
|
const MIN_TYRE_NORMAL_FORCE_NEWTONS = 1;
|
||||||
const DEFAULT_ORBIT_PITCH = 0.48;
|
const DEFAULT_ORBIT_PITCH = 0.48;
|
||||||
const CAMERA_RETURN_DELAY_SECONDS = 1.2;
|
const CAMERA_RETURN_DELAY_SECONDS = 1.2;
|
||||||
const CAMERA_RETURN_DURATION_SECONDS = 2;
|
const CAMERA_RETURN_DURATION_SECONDS = 2;
|
||||||
@@ -79,12 +86,20 @@ interface NativeTransform extends NativeObject {
|
|||||||
getRotation(): NativeQuaternion;
|
getRotation(): NativeQuaternion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NativeRaycastInfo extends NativeObject {
|
||||||
|
get_m_contactNormalWS(): NativeVector3;
|
||||||
|
get_m_contactPointWS(): NativeVector3;
|
||||||
|
get_m_wheelAxleWS(): NativeVector3;
|
||||||
|
}
|
||||||
|
|
||||||
interface NativeWheelInfo extends NativeObject {
|
interface NativeWheelInfo extends NativeObject {
|
||||||
set_m_suspensionStiffness(value: number): void;
|
set_m_suspensionStiffness(value: number): void;
|
||||||
set_m_wheelsDampingRelaxation(value: number): void;
|
set_m_wheelsDampingRelaxation(value: number): void;
|
||||||
set_m_wheelsDampingCompression(value: number): void;
|
set_m_wheelsDampingCompression(value: number): void;
|
||||||
set_m_frictionSlip(value: number): void;
|
set_m_frictionSlip(value: number): void;
|
||||||
set_m_rollInfluence(value: number): void;
|
set_m_rollInfluence(value: number): void;
|
||||||
|
get_m_wheelsSuspensionForce(): number;
|
||||||
|
get_m_raycastInfo(): NativeRaycastInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NativeRaycastVehicle extends NativeObject {
|
interface NativeRaycastVehicle extends NativeObject {
|
||||||
@@ -102,6 +117,7 @@ interface NativeRaycastVehicle extends NativeObject {
|
|||||||
setBrake(force: number, wheel: number): void;
|
setBrake(force: number, wheel: number): void;
|
||||||
setSteeringValue(value: number, wheel: number): void;
|
setSteeringValue(value: number, wheel: number): void;
|
||||||
getNumWheels(): number;
|
getNumWheels(): number;
|
||||||
|
getWheelInfo(wheel: number): NativeWheelInfo;
|
||||||
updateWheelTransform(wheel: number, interpolated: boolean): void;
|
updateWheelTransform(wheel: number, interpolated: boolean): void;
|
||||||
getWheelTransformWS(wheel: number): NativeTransform;
|
getWheelTransformWS(wheel: number): NativeTransform;
|
||||||
getForwardVector(): NativeVector3;
|
getForwardVector(): NativeVector3;
|
||||||
@@ -126,6 +142,11 @@ interface NativeDynamicsWorld extends NativeObject {
|
|||||||
removeAction(action: NativeObject): void;
|
removeAction(action: NativeObject): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NativeRigidBody extends NativeObject {
|
||||||
|
applyImpulse(impulse: NativeVector3, relativePosition: NativeVector3): void;
|
||||||
|
setActivationState(state: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
interface PhysicsSystemAccess {
|
interface PhysicsSystemAccess {
|
||||||
systems: {
|
systems: {
|
||||||
rigidbody: {
|
rigidbody: {
|
||||||
@@ -137,7 +158,7 @@ interface PhysicsSystemAccess {
|
|||||||
|
|
||||||
interface NativeRigidBodyAccess {
|
interface NativeRigidBodyAccess {
|
||||||
rigidbody?: {
|
rigidbody?: {
|
||||||
body: NativeObject | null;
|
body: NativeRigidBody | null;
|
||||||
linearVelocity: Vec3;
|
linearVelocity: Vec3;
|
||||||
angularVelocity: Vec3;
|
angularVelocity: Vec3;
|
||||||
teleport(position: Vec3, rotation?: Vec3 | Quat): void;
|
teleport(position: Vec3, rotation?: Vec3 | Quat): void;
|
||||||
@@ -192,6 +213,13 @@ export class SimulationUgvController {
|
|||||||
private readonly smoothedCamera = new Vec3();
|
private readonly smoothedCamera = new Vec3();
|
||||||
private readonly limitedLinearVelocity = new Vec3();
|
private readonly limitedLinearVelocity = new Vec3();
|
||||||
private readonly limitedAngularVelocity = new Vec3();
|
private readonly limitedAngularVelocity = new Vec3();
|
||||||
|
private readonly tyreContactNormal = new Vec3();
|
||||||
|
private readonly tyreLateralDirection = new Vec3();
|
||||||
|
private readonly tyreLongitudinalDirection = new Vec3();
|
||||||
|
private readonly tyreContactPoint = new Vec3();
|
||||||
|
private readonly tyreRelativePosition = new Vec3();
|
||||||
|
private readonly tyreAngularContactVelocity = new Vec3();
|
||||||
|
private readonly tyreContactVelocity = new Vec3();
|
||||||
private readonly spawnPosition = UGV_SPAWN_POSITION.clone();
|
private readonly spawnPosition = UGV_SPAWN_POSITION.clone();
|
||||||
private orbitPointerId: number | null = null;
|
private orbitPointerId: number | null = null;
|
||||||
private orbitPointerX = 0;
|
private orbitPointerX = 0;
|
||||||
@@ -204,8 +232,9 @@ export class SimulationUgvController {
|
|||||||
private active = false;
|
private active = false;
|
||||||
private vehicleEntity: Entity | null = null;
|
private vehicleEntity: Entity | null = null;
|
||||||
private vehicle: NativeRaycastVehicle | null = null;
|
private vehicle: NativeRaycastVehicle | null = null;
|
||||||
private vehicleTuning: NativeObject | null = null;
|
|
||||||
private vehicleRaycaster: NativeObject | null = null;
|
private vehicleRaycaster: NativeObject | null = null;
|
||||||
|
private tyreImpulseNative: NativeVector3 | null = null;
|
||||||
|
private tyreRelativePositionNative: NativeVector3 | null = null;
|
||||||
private dynamicsWorld: NativeDynamicsWorld | null = null;
|
private dynamicsWorld: NativeDynamicsWorld | null = null;
|
||||||
private chassisMaterial: StandardMaterial | null = null;
|
private chassisMaterial: StandardMaterial | null = null;
|
||||||
private wheelMaterial: StandardMaterial | null = null;
|
private wheelMaterial: StandardMaterial | null = null;
|
||||||
@@ -308,9 +337,27 @@ export class SimulationUgvController {
|
|||||||
const braking = this.pressed.has("Space");
|
const braking = this.pressed.has("Space");
|
||||||
const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody;
|
const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody;
|
||||||
const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6;
|
const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6;
|
||||||
|
const nativeForward = this.vehicle.getForwardVector();
|
||||||
|
const nativeForwardLength = Math.hypot(
|
||||||
|
nativeForward.x(),
|
||||||
|
nativeForward.y(),
|
||||||
|
nativeForward.z(),
|
||||||
|
);
|
||||||
|
const longitudinalSpeedMetersPerSecond = rigidbody && nativeForwardLength > 0.001
|
||||||
|
? Math.abs(
|
||||||
|
(
|
||||||
|
rigidbody.linearVelocity.x * nativeForward.x()
|
||||||
|
+ rigidbody.linearVelocity.y * nativeForward.y()
|
||||||
|
+ rigidbody.linearVelocity.z * nativeForward.z()
|
||||||
|
) / nativeForwardLength,
|
||||||
|
)
|
||||||
|
: Math.abs(speedMetersPerSecond);
|
||||||
const maxSpeed = this.settings.maxSpeedMetersPerSecond;
|
const maxSpeed = this.settings.maxSpeedMetersPerSecond;
|
||||||
const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180;
|
const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180;
|
||||||
const pureTurn = !braking && forwardInput === 0 && turnInput !== 0;
|
const pureTurn = !braking && forwardInput === 0 && turnInput !== 0;
|
||||||
|
const holding = !braking && forwardInput === 0 && turnInput === 0;
|
||||||
|
const parkingBrakeEngaged = holding
|
||||||
|
&& longitudinalSpeedMetersPerSecond <= PARKING_BRAKE_ENGAGE_SPEED_MPS;
|
||||||
const desiredSpeed = forwardInput * maxSpeed;
|
const desiredSpeed = forwardInput * maxSpeed;
|
||||||
const speedError = desiredSpeed - speedMetersPerSecond;
|
const speedError = desiredSpeed - speedMetersPerSecond;
|
||||||
const speedResponseRange = Math.max(0.35, maxSpeed * 0.2);
|
const speedResponseRange = Math.max(0.35, maxSpeed * 0.2);
|
||||||
@@ -325,13 +372,27 @@ export class SimulationUgvController {
|
|||||||
const leftCommand = clamp(forwardCommand - turnCommand, -1, 1);
|
const leftCommand = clamp(forwardCommand - turnCommand, -1, 1);
|
||||||
const rightCommand = clamp(forwardCommand + turnCommand, -1, 1);
|
const rightCommand = clamp(forwardCommand + turnCommand, -1, 1);
|
||||||
const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel;
|
const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel;
|
||||||
|
const brakeDeceleration = braking
|
||||||
|
? SERVICE_BRAKE_DECELERATION_MPS2
|
||||||
|
: holding
|
||||||
|
? parkingBrakeEngaged
|
||||||
|
? PARKING_BRAKE_HOLD_DECELERATION_MPS2
|
||||||
|
: COAST_DECELERATION_MPS2
|
||||||
|
: 0;
|
||||||
|
const wheelBrakeForce = this.settings.massKg * brakeDeceleration
|
||||||
|
/ Math.max(1, this.wheelDefinitions.length);
|
||||||
|
|
||||||
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
||||||
const definition = this.wheelDefinitions[index];
|
const definition = this.wheelDefinitions[index];
|
||||||
const command = definition.left ? leftCommand : rightCommand;
|
const command = definition.left ? leftCommand : rightCommand;
|
||||||
|
// Parking contact is solved below with one 2D Coulomb limit; disable the
|
||||||
|
// raycast vehicle's parallel friction impulse so grip is not counted twice.
|
||||||
|
this.vehicle.getWheelInfo(index).set_m_frictionSlip(
|
||||||
|
parkingBrakeEngaged ? 0 : TYRE_FRICTION_SLIP,
|
||||||
|
);
|
||||||
this.vehicle.setSteeringValue(0, index);
|
this.vehicle.setSteeringValue(0, index);
|
||||||
this.vehicle.applyEngineForce(command * engineForce, index);
|
this.vehicle.applyEngineForce(command * engineForce, index);
|
||||||
this.vehicle.setBrake(0, index);
|
this.vehicle.setBrake(wheelBrakeForce, index);
|
||||||
this.vehicle.updateWheelTransform(index, true);
|
this.vehicle.updateWheelTransform(index, true);
|
||||||
const transform = this.vehicle.getWheelTransformWS(index);
|
const transform = this.vehicle.getWheelTransformWS(index);
|
||||||
const position = transform.getOrigin();
|
const position = transform.getOrigin();
|
||||||
@@ -340,22 +401,21 @@ export class SimulationUgvController {
|
|||||||
definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w());
|
definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = rigidbody?.body ?? null;
|
||||||
|
if (rigidbody && body) {
|
||||||
|
this.applyParkingTyreContact(
|
||||||
|
deltaSeconds,
|
||||||
|
rigidbody,
|
||||||
|
body,
|
||||||
|
parkingBrakeEngaged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (rigidbody) {
|
if (rigidbody) {
|
||||||
const linearVelocity = rigidbody.linearVelocity;
|
const linearVelocity = rigidbody.linearVelocity;
|
||||||
let nextLinearX = linearVelocity.x;
|
let nextLinearX = linearVelocity.x;
|
||||||
let nextLinearZ = linearVelocity.z;
|
let nextLinearZ = linearVelocity.z;
|
||||||
let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ);
|
let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ);
|
||||||
const coasting = !braking && forwardInput === 0 && turnInput === 0;
|
|
||||||
if ((braking || coasting) && horizontalSpeed > 0.001) {
|
|
||||||
const deceleration = braking
|
|
||||||
? SERVICE_BRAKE_DECELERATION_MPS2
|
|
||||||
: COAST_DECELERATION_MPS2;
|
|
||||||
const nextSpeed = Math.max(0, horizontalSpeed - deceleration * Math.max(0, deltaSeconds));
|
|
||||||
const scale = nextSpeed / horizontalSpeed;
|
|
||||||
nextLinearX *= scale;
|
|
||||||
nextLinearZ *= scale;
|
|
||||||
horizontalSpeed = nextSpeed;
|
|
||||||
}
|
|
||||||
if (pureTurn && horizontalSpeed > 0.001) {
|
if (pureTurn && horizontalSpeed > 0.001) {
|
||||||
const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8);
|
const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8);
|
||||||
nextLinearX *= pivotDamping;
|
nextLinearX *= pivotDamping;
|
||||||
@@ -372,9 +432,6 @@ export class SimulationUgvController {
|
|||||||
rigidbody.linearVelocity = this.limitedLinearVelocity;
|
rigidbody.linearVelocity = this.limitedLinearVelocity;
|
||||||
}
|
}
|
||||||
const angularVelocity = rigidbody.angularVelocity;
|
const angularVelocity = rigidbody.angularVelocity;
|
||||||
const attitudeDamping = braking
|
|
||||||
? Math.exp(-Math.max(0, deltaSeconds) * BRAKE_ATTITUDE_DAMPING)
|
|
||||||
: 1;
|
|
||||||
let nextAngularY = angularVelocity.y;
|
let nextAngularY = angularVelocity.y;
|
||||||
if (pureTurn) {
|
if (pureTurn) {
|
||||||
const desiredYawRate = -turnInput * maxTurnRate;
|
const desiredYawRate = -turnInput * maxTurnRate;
|
||||||
@@ -387,24 +444,137 @@ export class SimulationUgvController {
|
|||||||
} else if (Math.abs(angularVelocity.y) > maxTurnRate) {
|
} else if (Math.abs(angularVelocity.y) > maxTurnRate) {
|
||||||
nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate;
|
nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate;
|
||||||
}
|
}
|
||||||
if (attitudeDamping !== 1 || nextAngularY !== angularVelocity.y) {
|
if (nextAngularY !== angularVelocity.y) {
|
||||||
this.limitedAngularVelocity.set(
|
this.limitedAngularVelocity.set(
|
||||||
angularVelocity.x * attitudeDamping,
|
angularVelocity.x,
|
||||||
nextAngularY,
|
nextAngularY,
|
||||||
angularVelocity.z * attitudeDamping,
|
angularVelocity.z,
|
||||||
);
|
);
|
||||||
rigidbody.angularVelocity = this.limitedAngularVelocity;
|
rigidbody.angularVelocity = this.limitedAngularVelocity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody?.body as {
|
body?.setActivationState(DISABLE_DEACTIVATION);
|
||||||
setActivationState?: (state: number) => void;
|
|
||||||
} | null;
|
|
||||||
body?.setActivationState?.(DISABLE_DEACTIVATION);
|
|
||||||
if (this.vehicleEntity.getPosition().y < -8) this.reset();
|
if (this.vehicleEntity.getPosition().y < -8) this.reset();
|
||||||
this.updateCamera(deltaSeconds);
|
this.updateCamera(deltaSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyParkingTyreContact(
|
||||||
|
deltaSeconds: number,
|
||||||
|
rigidbody: NonNullable<NativeRigidBodyAccess["rigidbody"]>,
|
||||||
|
body: NativeRigidBody,
|
||||||
|
parkingBrakeEngaged: boolean,
|
||||||
|
): void {
|
||||||
|
if (!this.vehicle || !this.vehicleEntity || !this.tyreImpulseNative
|
||||||
|
|| !this.tyreRelativePositionNative) return;
|
||||||
|
|
||||||
|
if (!parkingBrakeEngaged) return;
|
||||||
|
|
||||||
|
const timeStep = Math.min(Math.max(0, deltaSeconds), 1 / 30);
|
||||||
|
if (timeStep === 0) return;
|
||||||
|
const wheelEffectiveMass = this.settings.massKg
|
||||||
|
/ Math.max(1, this.wheelDefinitions.length);
|
||||||
|
const chassisPosition = this.vehicleEntity.getPosition();
|
||||||
|
|
||||||
|
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
|
||||||
|
const wheel = this.vehicle.getWheelInfo(index);
|
||||||
|
const raycast = wheel.get_m_raycastInfo();
|
||||||
|
const normalForce = wheel.get_m_wheelsSuspensionForce();
|
||||||
|
if (!Number.isFinite(normalForce) || normalForce < MIN_TYRE_NORMAL_FORCE_NEWTONS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeNormal = raycast.get_m_contactNormalWS();
|
||||||
|
this.tyreContactNormal.set(nativeNormal.x(), nativeNormal.y(), nativeNormal.z());
|
||||||
|
if (this.tyreContactNormal.lengthSq() < 0.001) continue;
|
||||||
|
this.tyreContactNormal.normalize();
|
||||||
|
|
||||||
|
const nativeAxle = raycast.get_m_wheelAxleWS();
|
||||||
|
this.tyreLateralDirection.set(nativeAxle.x(), nativeAxle.y(), nativeAxle.z());
|
||||||
|
this.tyreLateralDirection.addScaled(
|
||||||
|
this.tyreContactNormal,
|
||||||
|
-this.tyreLateralDirection.dot(this.tyreContactNormal),
|
||||||
|
);
|
||||||
|
if (this.tyreLateralDirection.lengthSq() < 0.001) continue;
|
||||||
|
this.tyreLateralDirection.normalize();
|
||||||
|
this.tyreLongitudinalDirection.cross(
|
||||||
|
this.tyreContactNormal,
|
||||||
|
this.tyreLateralDirection,
|
||||||
|
).normalize();
|
||||||
|
|
||||||
|
const nativeContactPoint = raycast.get_m_contactPointWS();
|
||||||
|
this.tyreContactPoint.set(
|
||||||
|
nativeContactPoint.x(),
|
||||||
|
nativeContactPoint.y(),
|
||||||
|
nativeContactPoint.z(),
|
||||||
|
);
|
||||||
|
this.tyreRelativePosition.sub2(this.tyreContactPoint, chassisPosition);
|
||||||
|
this.tyreAngularContactVelocity.cross(
|
||||||
|
rigidbody.angularVelocity,
|
||||||
|
this.tyreRelativePosition,
|
||||||
|
);
|
||||||
|
this.tyreContactVelocity.add2(
|
||||||
|
rigidbody.linearVelocity,
|
||||||
|
this.tyreAngularContactVelocity,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lateralSlipSpeed = this.tyreContactVelocity.dot(this.tyreLateralDirection);
|
||||||
|
const longitudinalSlipSpeed = this.tyreContactVelocity.dot(
|
||||||
|
this.tyreLongitudinalDirection,
|
||||||
|
);
|
||||||
|
// Static tyre friction is a contact constraint: it balances the component
|
||||||
|
// of gravity along the surface and damps slip at the contact patch. The
|
||||||
|
// force still passes through a Coulomb circle and is applied at the wheel,
|
||||||
|
// so the chassis remains a fully dynamic rigid body.
|
||||||
|
const lateralGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
|
||||||
|
* this.tyreLateralDirection.y;
|
||||||
|
const longitudinalGravityAcceleration = -GRAVITY_METERS_PER_SECOND_SQUARED
|
||||||
|
* this.tyreLongitudinalDirection.y;
|
||||||
|
const trialLateralForce = -wheelEffectiveMass * (
|
||||||
|
lateralGravityAcceleration
|
||||||
|
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * lateralSlipSpeed
|
||||||
|
);
|
||||||
|
const trialLongitudinalForce = -wheelEffectiveMass * (
|
||||||
|
longitudinalGravityAcceleration
|
||||||
|
+ TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND * longitudinalSlipSpeed
|
||||||
|
);
|
||||||
|
const staticFrictionLimit = TYRE_STATIC_FRICTION_COEFFICIENT * normalForce;
|
||||||
|
let lateralForce = trialLateralForce;
|
||||||
|
let longitudinalForce = trialLongitudinalForce;
|
||||||
|
|
||||||
|
if (Math.hypot(trialLateralForce, trialLongitudinalForce) > staticFrictionLimit) {
|
||||||
|
const slipSpeed = Math.hypot(lateralSlipSpeed, longitudinalSlipSpeed);
|
||||||
|
const kineticFrictionLimit = TYRE_KINETIC_FRICTION_COEFFICIENT * normalForce;
|
||||||
|
if (slipSpeed > 0.0001) {
|
||||||
|
lateralForce = -(lateralSlipSpeed / slipSpeed) * kineticFrictionLimit;
|
||||||
|
longitudinalForce = -(longitudinalSlipSpeed / slipSpeed) * kineticFrictionLimit;
|
||||||
|
} else {
|
||||||
|
const forceScale = staticFrictionLimit
|
||||||
|
/ Math.hypot(trialLateralForce, trialLongitudinalForce);
|
||||||
|
lateralForce = trialLateralForce * forceScale;
|
||||||
|
longitudinalForce = trialLongitudinalForce * forceScale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lateralImpulse = lateralForce * timeStep;
|
||||||
|
const longitudinalImpulse = longitudinalForce * timeStep;
|
||||||
|
this.tyreImpulseNative.setValue(
|
||||||
|
this.tyreLateralDirection.x * lateralImpulse
|
||||||
|
+ this.tyreLongitudinalDirection.x * longitudinalImpulse,
|
||||||
|
this.tyreLateralDirection.y * lateralImpulse
|
||||||
|
+ this.tyreLongitudinalDirection.y * longitudinalImpulse,
|
||||||
|
this.tyreLateralDirection.z * lateralImpulse
|
||||||
|
+ this.tyreLongitudinalDirection.z * longitudinalImpulse,
|
||||||
|
);
|
||||||
|
this.tyreRelativePositionNative.setValue(
|
||||||
|
this.tyreRelativePosition.x,
|
||||||
|
this.tyreRelativePosition.y,
|
||||||
|
this.tyreRelativePosition.z,
|
||||||
|
);
|
||||||
|
body.applyImpulse(this.tyreImpulseNative, this.tyreRelativePositionNative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> {
|
private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> {
|
||||||
const models = collisionWorld.findComponents("model") as ModelComponent[];
|
const models = collisionWorld.findComponents("model") as ModelComponent[];
|
||||||
if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV.");
|
if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV.");
|
||||||
@@ -428,7 +598,12 @@ export class SimulationUgvController {
|
|||||||
this.app,
|
this.app,
|
||||||
meshInstance.mesh,
|
meshInstance.mesh,
|
||||||
new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()),
|
new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()),
|
||||||
targetRatio,
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(
|
||||||
|
Math.floor((meshInstance.mesh.primitive[0]?.count ?? 0) / 3) * targetRatio,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (!physicsMesh) continue;
|
if (!physicsMesh) continue;
|
||||||
const entity = new Entity(`UGV physics proxy ${index + 1}`);
|
const entity = new Entity(`UGV physics proxy ${index + 1}`);
|
||||||
@@ -464,8 +639,9 @@ export class SimulationUgvController {
|
|||||||
type: "dynamic",
|
type: "dynamic",
|
||||||
mass: this.settings.massKg,
|
mass: this.settings.massKg,
|
||||||
friction: 0.85,
|
friction: 0.85,
|
||||||
linearDamping: 0.08,
|
rollingFriction: 0.12,
|
||||||
angularDamping: 0.45,
|
linearDamping: 0.12,
|
||||||
|
angularDamping: 0.6,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05));
|
this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05));
|
||||||
@@ -525,7 +701,10 @@ export class SimulationUgvController {
|
|||||||
applyMaterial(wheelMesh, this.wheelMaterial);
|
applyMaterial(wheelMesh, this.wheelMaterial);
|
||||||
anchor.addChild(wheelMesh);
|
anchor.addChild(wheelMesh);
|
||||||
vehicle.addChild(anchor);
|
vehicle.addChild(anchor);
|
||||||
this.wheelDefinitions.push({ ...definition, anchor });
|
this.wheelDefinitions.push({
|
||||||
|
...definition,
|
||||||
|
anchor,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
vehicle.setLocalPosition(this.spawnPosition);
|
vehicle.setLocalPosition(this.spawnPosition);
|
||||||
@@ -559,16 +738,17 @@ export class SimulationUgvController {
|
|||||||
wheel.set_m_suspensionStiffness(24);
|
wheel.set_m_suspensionStiffness(24);
|
||||||
wheel.set_m_wheelsDampingRelaxation(3.2);
|
wheel.set_m_wheelsDampingRelaxation(3.2);
|
||||||
wheel.set_m_wheelsDampingCompression(4.8);
|
wheel.set_m_wheelsDampingCompression(4.8);
|
||||||
wheel.set_m_frictionSlip(5.5);
|
wheel.set_m_frictionSlip(TYRE_FRICTION_SLIP);
|
||||||
wheel.set_m_rollInfluence(0.08);
|
wheel.set_m_rollInfluence(0.08);
|
||||||
}
|
}
|
||||||
this.ammo.destroy(axle);
|
this.ammo.destroy(axle);
|
||||||
this.ammo.destroy(direction);
|
this.ammo.destroy(direction);
|
||||||
this.ammo.destroy(connection);
|
this.ammo.destroy(connection);
|
||||||
|
this.tyreImpulseNative = new this.ammo.btVector3(0, 0, 0);
|
||||||
|
this.tyreRelativePositionNative = new this.ammo.btVector3(0, 0, 0);
|
||||||
|
|
||||||
dynamicsWorld.addAction(nativeVehicle);
|
dynamicsWorld.addAction(nativeVehicle);
|
||||||
this.vehicleEntity = vehicle;
|
this.vehicleEntity = vehicle;
|
||||||
this.vehicleTuning = tuning;
|
|
||||||
this.vehicleRaycaster = raycaster;
|
this.vehicleRaycaster = raycaster;
|
||||||
this.vehicle = nativeVehicle;
|
this.vehicle = nativeVehicle;
|
||||||
this.dynamicsWorld = dynamicsWorld;
|
this.dynamicsWorld = dynamicsWorld;
|
||||||
@@ -684,10 +864,12 @@ export class SimulationUgvController {
|
|||||||
}
|
}
|
||||||
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
|
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
|
||||||
if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject));
|
if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject));
|
||||||
if (this.vehicleTuning) runCleanup("destroy vehicle tuning", () => this.ammo.destroy(this.vehicleTuning as NativeObject));
|
if (this.tyreImpulseNative) runCleanup("destroy tyre impulse vector", () => this.ammo.destroy(this.tyreImpulseNative as NativeObject));
|
||||||
|
if (this.tyreRelativePositionNative) runCleanup("destroy tyre relative-position vector", () => this.ammo.destroy(this.tyreRelativePositionNative as NativeObject));
|
||||||
this.vehicle = null;
|
this.vehicle = null;
|
||||||
this.vehicleRaycaster = null;
|
this.vehicleRaycaster = null;
|
||||||
this.vehicleTuning = null;
|
this.tyreImpulseNative = null;
|
||||||
|
this.tyreRelativePositionNative = null;
|
||||||
this.dynamicsWorld = null;
|
this.dynamicsWorld = null;
|
||||||
|
|
||||||
if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy());
|
if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy());
|
||||||
@@ -824,7 +1006,7 @@ function createPhysicsProxyMesh(
|
|||||||
app: Application,
|
app: Application,
|
||||||
source: Mesh,
|
source: Mesh,
|
||||||
localTransform: Mat4,
|
localTransform: Mat4,
|
||||||
targetRatio: number,
|
targetTriangleCount: number,
|
||||||
): Mesh | null {
|
): Mesh | null {
|
||||||
const primitive = source.primitive[0];
|
const primitive = source.primitive[0];
|
||||||
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
|
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
|
||||||
@@ -858,8 +1040,11 @@ function createPhysicsProxyMesh(
|
|||||||
if (triangleIndexCount < 3) return null;
|
if (triangleIndexCount < 3) return null;
|
||||||
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
|
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
|
||||||
|
|
||||||
if (targetRatio < 1) {
|
const targetIndexCount = Math.max(
|
||||||
const targetIndexCount = Math.max(3, Math.floor(indices.length * targetRatio / 3) * 3);
|
3,
|
||||||
|
Math.min(indices.length, Math.floor(targetTriangleCount) * 3),
|
||||||
|
);
|
||||||
|
if (indices.length > targetIndexCount) {
|
||||||
const [simplifiedIndices] = MeshoptSimplifier.simplify(
|
const [simplifiedIndices] = MeshoptSimplifier.simplify(
|
||||||
indices,
|
indices,
|
||||||
transformed,
|
transformed,
|
||||||
@@ -868,6 +1053,26 @@ function createPhysicsProxyMesh(
|
|||||||
PHYSICS_PROXY_ERROR,
|
PHYSICS_PROXY_ERROR,
|
||||||
);
|
);
|
||||||
indices = new Uint32Array(simplifiedIndices);
|
indices = new Uint32Array(simplifiedIndices);
|
||||||
|
if (indices.length > targetIndexCount) {
|
||||||
|
// Topologically noisy scanner meshes can make the quality simplifier stop
|
||||||
|
// above its requested target. Ammo must never receive that unbounded result:
|
||||||
|
// the spatial fallback preserves the surface envelope while enforcing the
|
||||||
|
// same deterministic physics budget for every imported location.
|
||||||
|
const [boundedIndices] = MeshoptSimplifier.simplifySloppy(
|
||||||
|
indices,
|
||||||
|
transformed,
|
||||||
|
3,
|
||||||
|
null,
|
||||||
|
targetIndexCount,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
indices = new Uint32Array(boundedIndices);
|
||||||
|
}
|
||||||
|
if (indices.length > targetIndexCount) {
|
||||||
|
throw new Error(
|
||||||
|
`Physics proxy превысил лимит: ${Math.floor(indices.length / 3)} > ${Math.floor(targetIndexCount / 3)} треугольников.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactedIndices = new Uint32Array(indices);
|
const compactedIndices = new Uint32Array(indices);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export interface E31LaboratoryResult {
|
|||||||
limitations: readonly string[];
|
limitations: readonly string[];
|
||||||
access: "read-only";
|
access: "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E32LaboratoryResult {
|
export interface E32LaboratoryResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string | null;
|
createdAtUtc: string | null;
|
||||||
@@ -53,7 +52,6 @@ export interface E32LaboratoryResult {
|
|||||||
};
|
};
|
||||||
access: "read-only";
|
access: "read-only";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E33LaboratoryResult {
|
export interface E33LaboratoryResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string | null;
|
createdAtUtc: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export const CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS = 1;
|
||||||
|
export const CANONICAL_RECORDED_LAB_SPATIAL_PROFILE = "source-paced-ground-v3";
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabPackedCellEvidence {
|
||||||
|
centersBodyXyM: Float32Array;
|
||||||
|
zBoundsM: Float32Array;
|
||||||
|
stateCodes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabBodyGroundFrame {
|
||||||
|
originMapXyzM: readonly [number, number, number];
|
||||||
|
sensorOriginMapXyzM: readonly [number, number, number];
|
||||||
|
basisMapFromBody: readonly [
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabTgsCostmap {
|
||||||
|
centersXyM: readonly (readonly [number, number])[];
|
||||||
|
stateCodes: readonly number[];
|
||||||
|
zBoundsM: readonly (readonly [number | null, number | null])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalRecordedLabTgsIsCurrent(
|
||||||
|
currentTimeNs: number,
|
||||||
|
anchorTimeNs: number,
|
||||||
|
historySeconds = CANONICAL_RECORDED_LAB_TGS_HISTORY_SECONDS,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(currentTimeNs)
|
||||||
|
|| !Number.isSafeInteger(anchorTimeNs)
|
||||||
|
|| !Number.isFinite(historySeconds)
|
||||||
|
|| historySeconds <= 0
|
||||||
|
) return false;
|
||||||
|
const ageNs = currentTimeNs - anchorTimeNs;
|
||||||
|
return ageNs >= 0 && ageNs <= Math.round(historySeconds * 1_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalMapGravityLocalPointToBodyGround(
|
||||||
|
point: readonly [number, number, number],
|
||||||
|
anchor: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
current: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
): readonly [number, number, number] {
|
||||||
|
// TGS is translation-only map-gravity-local: its axes are map axes and its
|
||||||
|
// origin is the LiDAR at the source frame. It is not an anchor body frame.
|
||||||
|
const map: readonly [number, number, number] = [
|
||||||
|
anchor.sensorOriginMapXyzM[0] + point[0],
|
||||||
|
anchor.sensorOriginMapXyzM[1] + point[1],
|
||||||
|
anchor.sensorOriginMapXyzM[2] + point[2],
|
||||||
|
];
|
||||||
|
const delta: readonly [number, number, number] = [
|
||||||
|
map[0] - current.originMapXyzM[0],
|
||||||
|
map[1] - current.originMapXyzM[1],
|
||||||
|
map[2] - current.originMapXyzM[2],
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
current.basisMapFromBody[0][0] * delta[0]
|
||||||
|
+ current.basisMapFromBody[1][0] * delta[1]
|
||||||
|
+ current.basisMapFromBody[2][0] * delta[2],
|
||||||
|
current.basisMapFromBody[0][1] * delta[0]
|
||||||
|
+ current.basisMapFromBody[1][1] * delta[1]
|
||||||
|
+ current.basisMapFromBody[2][1] * delta[2],
|
||||||
|
current.basisMapFromBody[0][2] * delta[0]
|
||||||
|
+ current.basisMapFromBody[1][2] * delta[1]
|
||||||
|
+ current.basisMapFromBody[2][2] * delta[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canonicalRecordedLabPackedTgsCells(
|
||||||
|
costmap: CanonicalRecordedLabTgsCostmap,
|
||||||
|
anchor: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
current: CanonicalRecordedLabBodyGroundFrame,
|
||||||
|
): CanonicalRecordedLabPackedCellEvidence {
|
||||||
|
if (
|
||||||
|
costmap.centersXyM.length !== costmap.stateCodes.length
|
||||||
|
|| costmap.centersXyM.length !== costmap.zBoundsM.length
|
||||||
|
) throw new Error("Canonical recorded LAB TGS accounting changed");
|
||||||
|
const centers: number[] = [];
|
||||||
|
const zBounds: number[] = [];
|
||||||
|
costmap.centersXyM.forEach(([x, y], index) => {
|
||||||
|
const bounds = costmap.zBoundsM[index] ?? [null, null];
|
||||||
|
const center = canonicalMapGravityLocalPointToBodyGround([x, y, 0], anchor, current);
|
||||||
|
centers.push(center[0], center[1]);
|
||||||
|
if (bounds[0] === null || bounds[1] === null) {
|
||||||
|
zBounds.push(Number.NaN, Number.NaN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bottom = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[0]], anchor, current);
|
||||||
|
const top = canonicalMapGravityLocalPointToBodyGround([x, y, bounds[1]], anchor, current);
|
||||||
|
zBounds.push(Math.min(bottom[2], top[2]), Math.max(bottom[2], top[2]));
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
centersBodyXyM: Float32Array.from(centers),
|
||||||
|
zBoundsM: Float32Array.from(zBounds),
|
||||||
|
stateCodes: Uint8Array.from(costmap.stateCodes),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import type { LaboratoryFetch } from "./advancedResults";
|
||||||
|
import { CANONICAL_RECORDED_LAB_SPATIAL_PROFILE } from "./canonicalRecordedLab";
|
||||||
|
|
||||||
|
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const SHA256 = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
export class CanonicalRecordedLabSpatialContractError extends Error {}
|
||||||
|
|
||||||
|
export interface CanonicalRecordedLabSpatialFrame {
|
||||||
|
targetTimeNs: number;
|
||||||
|
sourceTimeNs: number;
|
||||||
|
poseTimeNs: number;
|
||||||
|
trajectoryTimeNs: number;
|
||||||
|
sourcePointCount: number;
|
||||||
|
coordinateFrame: "body-ground";
|
||||||
|
sensorHeight: {
|
||||||
|
meters: number;
|
||||||
|
source: "local-source-cloud-ground-quantile-median" | "session-source-cloud-fallback";
|
||||||
|
sampleCount: number;
|
||||||
|
madM: number;
|
||||||
|
authority: "visual-derived";
|
||||||
|
};
|
||||||
|
spatialProfile: {
|
||||||
|
profileId: typeof CANONICAL_RECORDED_LAB_SPATIAL_PROFILE;
|
||||||
|
localSlamHistorySeconds: number;
|
||||||
|
localSlamRadiusM: number;
|
||||||
|
localSlamVoxelSizeM: number;
|
||||||
|
localSlamPointLimit: number;
|
||||||
|
};
|
||||||
|
bodyFrame: {
|
||||||
|
originMapXyzM: readonly [number, number, number];
|
||||||
|
sensorOriginMapXyzM: readonly [number, number, number];
|
||||||
|
basisMapFromBody: readonly [
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
sourcePointsBodyXyzM: readonly (readonly [number, number, number])[];
|
||||||
|
localSlamSourceFrameCount: number;
|
||||||
|
localSlamSourcePointCount: number;
|
||||||
|
localSlamBodyXyzM: readonly (readonly [number, number, number])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayValue(value: unknown, label: string): unknown[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидался массив.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exact(value: unknown, expected: unknown, label: string): void {
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: контракт изменён.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown, label: string): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось число.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerValue(value: unknown, label: string): number {
|
||||||
|
const parsed = numberValue(value, label);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}: ожидалось целое значение.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointList(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
): readonly (readonly [number, number, number])[] {
|
||||||
|
return arrayValue(value, label).map((entry, index) => {
|
||||||
|
const point = arrayValue(entry, `${label}[${index}]`).map(
|
||||||
|
(channel, channelIndex) => numberValue(channel, `${label}[${index}][${channelIndex}]`),
|
||||||
|
);
|
||||||
|
if (point.length !== 3) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(`${label}[${index}]: размер изменён.`);
|
||||||
|
}
|
||||||
|
return [point[0]!, point[1]!, point[2]!] as const;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCanonicalRecordedLabSpatialFrame(
|
||||||
|
sessionId: string,
|
||||||
|
generationSha256: string,
|
||||||
|
targetTimeNs: number,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
|
): Promise<CanonicalRecordedLabSpatialFrame> {
|
||||||
|
if (
|
||||||
|
!SAFE_SESSION_ID.test(sessionId)
|
||||||
|
|| !SHA256.test(generationSha256)
|
||||||
|
|| !Number.isSafeInteger(targetTimeNs)
|
||||||
|
|| targetTimeNs < 0
|
||||||
|
) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"Canonical LAB spatial identity недопустима.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
generation: generationSha256,
|
||||||
|
time_ns: String(targetTimeNs),
|
||||||
|
profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||||
|
});
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`
|
||||||
|
+ `/canonical-lab/spatial-frame?${query.toString()}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
`Canonical LAB spatial frame недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "canonical_lab.spatial_frame");
|
||||||
|
exact(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
|
"canonical_lab.spatial_frame.schema_version",
|
||||||
|
);
|
||||||
|
exact(payload.coordinate_frame, "body-ground", "canonical_lab.spatial_frame.coordinate_frame");
|
||||||
|
exact(payload.target_time_ns, targetTimeNs, "canonical_lab.spatial_frame.target_time_ns");
|
||||||
|
const sourcePoints = pointList(
|
||||||
|
payload.source_points_body_xyz_m,
|
||||||
|
"canonical_lab.spatial_frame.source_points",
|
||||||
|
);
|
||||||
|
const localSlam = pointList(
|
||||||
|
payload.local_slam_body_xyz_m,
|
||||||
|
"canonical_lab.spatial_frame.local_slam",
|
||||||
|
);
|
||||||
|
const sourcePointCount = integerValue(
|
||||||
|
payload.source_point_count,
|
||||||
|
"canonical_lab.spatial_frame.source_point_count",
|
||||||
|
);
|
||||||
|
const localSlamPointCount = integerValue(
|
||||||
|
payload.local_slam_point_count,
|
||||||
|
"canonical_lab.spatial_frame.local_slam_point_count",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
sourcePointCount !== sourcePoints.length
|
||||||
|
|| sourcePointCount > 100_000
|
||||||
|
|| localSlamPointCount !== localSlam.length
|
||||||
|
|| localSlam.length > 27_000
|
||||||
|
) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"Canonical LAB spatial accounting изменён.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bodyFrame = objectValue(payload.body_frame, "canonical_lab.spatial_frame.body_frame");
|
||||||
|
const origin = pointList(
|
||||||
|
[bodyFrame.origin_map_xyz_m],
|
||||||
|
"canonical_lab.spatial_frame.body_frame.origin",
|
||||||
|
)[0]!;
|
||||||
|
const sensorOrigin = pointList(
|
||||||
|
[bodyFrame.sensor_origin_map_xyz_m],
|
||||||
|
"canonical_lab.spatial_frame.body_frame.sensor_origin",
|
||||||
|
)[0]!;
|
||||||
|
const basisRows = pointList(
|
||||||
|
bodyFrame.basis_map_from_body,
|
||||||
|
"canonical_lab.spatial_frame.body_frame.basis",
|
||||||
|
);
|
||||||
|
if (basisRows.length !== 3) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"Canonical LAB spatial basis изменён.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const sensorHeight = objectValue(payload.sensor_height, "canonical_lab.spatial_frame.sensor_height");
|
||||||
|
if (
|
||||||
|
sensorHeight.source !== "local-source-cloud-ground-quantile-median"
|
||||||
|
&& sensorHeight.source !== "session-source-cloud-fallback"
|
||||||
|
) {
|
||||||
|
throw new CanonicalRecordedLabSpatialContractError(
|
||||||
|
"canonical_lab.spatial_frame.sensor_height.source: контракт изменён.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exact(
|
||||||
|
sensorHeight.authority,
|
||||||
|
"visual-derived",
|
||||||
|
"canonical_lab.spatial_frame.sensor_height.authority",
|
||||||
|
);
|
||||||
|
const spatialProfile = objectValue(
|
||||||
|
payload.spatial_profile,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
spatialProfile.profile_id,
|
||||||
|
CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.profile_id",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
targetTimeNs,
|
||||||
|
sourceTimeNs: integerValue(payload.source_time_ns, "canonical_lab.spatial_frame.source_time_ns"),
|
||||||
|
poseTimeNs: integerValue(payload.pose_time_ns, "canonical_lab.spatial_frame.pose_time_ns"),
|
||||||
|
trajectoryTimeNs: integerValue(
|
||||||
|
payload.trajectory_time_ns,
|
||||||
|
"canonical_lab.spatial_frame.trajectory_time_ns",
|
||||||
|
),
|
||||||
|
sourcePointCount,
|
||||||
|
coordinateFrame: "body-ground",
|
||||||
|
sensorHeight: {
|
||||||
|
meters: numberValue(sensorHeight.meters, "canonical_lab.spatial_frame.sensor_height.meters"),
|
||||||
|
source: sensorHeight.source,
|
||||||
|
sampleCount: integerValue(
|
||||||
|
sensorHeight.sample_count,
|
||||||
|
"canonical_lab.spatial_frame.sensor_height.sample_count",
|
||||||
|
),
|
||||||
|
madM: numberValue(sensorHeight.mad_m, "canonical_lab.spatial_frame.sensor_height.mad_m"),
|
||||||
|
authority: "visual-derived",
|
||||||
|
},
|
||||||
|
spatialProfile: {
|
||||||
|
profileId: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE,
|
||||||
|
localSlamHistorySeconds: numberValue(
|
||||||
|
spatialProfile.local_slam_history_seconds,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.history",
|
||||||
|
),
|
||||||
|
localSlamRadiusM: numberValue(
|
||||||
|
spatialProfile.local_slam_radius_m,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.radius",
|
||||||
|
),
|
||||||
|
localSlamVoxelSizeM: numberValue(
|
||||||
|
spatialProfile.local_slam_voxel_size_m,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.voxel",
|
||||||
|
),
|
||||||
|
localSlamPointLimit: integerValue(
|
||||||
|
spatialProfile.local_slam_point_limit,
|
||||||
|
"canonical_lab.spatial_frame.spatial_profile.limit",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
bodyFrame: {
|
||||||
|
originMapXyzM: origin,
|
||||||
|
sensorOriginMapXyzM: sensorOrigin,
|
||||||
|
basisMapFromBody: [basisRows[0]!, basisRows[1]!, basisRows[2]!],
|
||||||
|
},
|
||||||
|
sourcePointsBodyXyzM: sourcePoints,
|
||||||
|
localSlamSourceFrameCount: integerValue(
|
||||||
|
payload.local_slam_source_frame_count,
|
||||||
|
"canonical_lab.spatial_frame.local_slam_source_frames",
|
||||||
|
),
|
||||||
|
localSlamSourcePointCount: integerValue(
|
||||||
|
payload.local_slam_source_point_count,
|
||||||
|
"canonical_lab.spatial_frame.local_slam_source_points",
|
||||||
|
),
|
||||||
|
localSlamBodyXyzM: localSlam,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -154,6 +154,9 @@ export interface M4ThreatTimelineFrame {
|
|||||||
pointCloudSourceCount: number;
|
pointCloudSourceCount: number;
|
||||||
pointCloudSampleCount: number;
|
pointCloudSampleCount: number;
|
||||||
pointCloudLayer: "current-increment";
|
pointCloudLayer: "current-increment";
|
||||||
|
localSlamBodyXyzM?: readonly M4Point3[];
|
||||||
|
localSlamSourceFrameCount?: number;
|
||||||
|
localSlamSourcePointCount?: number;
|
||||||
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
|
cameraProjectedPointsXyd: readonly (readonly [number, number, number])[];
|
||||||
cameraProjectedSourceCount: number;
|
cameraProjectedSourceCount: number;
|
||||||
cameraProjectedPointCount: number;
|
cameraProjectedPointCount: number;
|
||||||
@@ -168,10 +171,11 @@ export interface M4ThreatTimelineFrame {
|
|||||||
|
|
||||||
export interface M4ThreatTimeline {
|
export interface M4ThreatTimeline {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
recordedSourceSessionId: string;
|
||||||
|
recordedSourceId: string;
|
||||||
imageWidth: 800;
|
imageWidth: 800;
|
||||||
imageHeight: 600;
|
imageHeight: 600;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
frameTimesNs: readonly number[];
|
frameTimesNs: readonly number[];
|
||||||
timelineStartSeconds: number;
|
timelineStartSeconds: number;
|
||||||
timelineEndSeconds: number;
|
timelineEndSeconds: number;
|
||||||
@@ -234,7 +238,7 @@ export interface M4ThreatPlaybackProgress {
|
|||||||
|
|
||||||
export interface M4ThreatPlaybackPointPack {
|
export interface M4ThreatPlaybackPointPack {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
pointOffsets: Uint32Array;
|
pointOffsets: Uint32Array;
|
||||||
pointsMapXyzM: Float32Array;
|
pointsMapXyzM: Float32Array;
|
||||||
@@ -257,7 +261,7 @@ export interface M4ThreatPlaybackChunkDescriptor {
|
|||||||
|
|
||||||
export interface M4ThreatPlaybackManifest {
|
export interface M4ThreatPlaybackManifest {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frameCount: 4489;
|
frameCount: number;
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
pointOffsets: Uint32Array;
|
pointOffsets: Uint32Array;
|
||||||
chunkFrameCount: 24;
|
chunkFrameCount: 24;
|
||||||
@@ -337,7 +341,7 @@ const motion = (value: unknown): M4ThreatMotion => {
|
|||||||
};
|
};
|
||||||
const resultId = (value: unknown): string => {
|
const resultId = (value: unknown): string => {
|
||||||
const parsed = text(value, "M4.6 result id");
|
const parsed = text(value, "M4.6 result id");
|
||||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
if (!/^[a-z0-9][a-z0-9-]{0,127}-[a-f0-9]{64}$/.test(parsed)) {
|
||||||
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
@@ -704,12 +708,8 @@ export async function fetchM4ThreatTimeline(
|
|||||||
exact(payload.result_id, result, "M4.6 timeline result");
|
exact(payload.result_id, result, "M4.6 timeline result");
|
||||||
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
exact(payload.authority, "replay-simulated", "M4.6 timeline authority");
|
||||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||||
exact(
|
const recordedSessionId = text(recorded.session_id, "M4.6 recorded session");
|
||||||
recorded.session_id,
|
const recordedSourceId = text(recorded.source_id, "M4.6 recorded source id");
|
||||||
"20260720T065719Z_viewer_live",
|
|
||||||
"M4.6 recorded session",
|
|
||||||
);
|
|
||||||
exact(recorded.source_id, "RAVNOVES00", "M4.6 recorded source id");
|
|
||||||
exact(
|
exact(
|
||||||
recorded.representation_id,
|
recorded.representation_id,
|
||||||
"registered-map-increment-v1",
|
"registered-map-increment-v1",
|
||||||
@@ -720,7 +720,10 @@ export async function fetchM4ThreatTimeline(
|
|||||||
"host-arrival-best-effort",
|
"host-arrival-best-effort",
|
||||||
"M4.6 recorded synchronization",
|
"M4.6 recorded synchronization",
|
||||||
);
|
);
|
||||||
const frameCount = exact(payload.frame_count, 4489, "M4.6 timeline frame count");
|
const frameCount = integer(payload.frame_count, "M4.6 timeline frame count");
|
||||||
|
if (frameCount < 1) {
|
||||||
|
throw new M4ThreatContractError("M4.6 timeline frame count: пустой timeline.");
|
||||||
|
}
|
||||||
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
const frameTimesNs = array(payload.frame_times_ns, "M4.6 timeline index").map(
|
||||||
(value) => integer(value, "M4.6 timeline time"),
|
(value) => integer(value, "M4.6 timeline time"),
|
||||||
);
|
);
|
||||||
@@ -738,7 +741,8 @@ export async function fetchM4ThreatTimeline(
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
resultId: result,
|
resultId: result,
|
||||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
recordedSourceSessionId: recordedSessionId,
|
||||||
|
recordedSourceId,
|
||||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||||
frameCount,
|
frameCount,
|
||||||
@@ -849,12 +853,14 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT,
|
||||||
cameraObstacleProjectionDelivery = null,
|
cameraObstacleProjectionDelivery = null,
|
||||||
playbackPointPack,
|
playbackPointPack,
|
||||||
|
includePoints = true,
|
||||||
}: {
|
}: {
|
||||||
fetcher?: LaboratoryFetch;
|
fetcher?: LaboratoryFetch;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
cameraObstacleProjectionDelivery?: M4ThreatTimeline["cameraObstacleProjectionDelivery"];
|
||||||
playbackPointPack?: M4ThreatPlaybackPointPack;
|
playbackPointPack?: M4ThreatPlaybackPointPack;
|
||||||
|
includePoints?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<M4ThreatTimelineChunk> {
|
): Promise<M4ThreatTimelineChunk> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -864,7 +870,7 @@ export async function fetchM4ThreatTimelineChunk(
|
|||||||
if (cameraObstacleProjectionDelivery !== null) {
|
if (cameraObstacleProjectionDelivery !== null) {
|
||||||
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
params.set("obstacle_projection", cameraObstacleProjectionDelivery);
|
||||||
}
|
}
|
||||||
if (playbackPointPack) params.set("include_points", "false");
|
if (playbackPointPack || !includePoints) params.set("include_points", "false");
|
||||||
const response = await fetcher(
|
const response = await fetcher(
|
||||||
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
`${endpointRoot}/${result}/timeline/chunk?${params}`,
|
||||||
{ headers: { Accept: "application/json" }, signal },
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
@@ -995,7 +1001,10 @@ export async function fetchM4ThreatPlaybackManifest(
|
|||||||
exact(manifest.result_id, result, "M4.6 playback result");
|
exact(manifest.result_id, result, "M4.6 playback result");
|
||||||
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
|
||||||
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
|
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
|
||||||
const frameCount = exact(integer(manifest.frame_count, "M4.6 playback frames"), 4489, "M4.6 playback frames");
|
const frameCount = integer(manifest.frame_count, "M4.6 playback frames");
|
||||||
|
if (frameCount < 1) {
|
||||||
|
throw new M4ThreatContractError("M4.6 playback frames: пустой playback недопустим.");
|
||||||
|
}
|
||||||
const pointCount = integer(manifest.point_count, "M4.6 playback points");
|
const pointCount = integer(manifest.point_count, "M4.6 playback points");
|
||||||
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
|
||||||
if (offsetsRaw.length !== frameCount + 1) {
|
if (offsetsRaw.length !== frameCount + 1) {
|
||||||
@@ -1332,6 +1341,17 @@ function parseTimelineFrame(
|
|||||||
"current-increment",
|
"current-increment",
|
||||||
"M4.6 timeline point layer",
|
"M4.6 timeline point layer",
|
||||||
),
|
),
|
||||||
|
localSlamBodyXyzM: item.local_slam_body_xyz_m === undefined
|
||||||
|
? []
|
||||||
|
: array(item.local_slam_body_xyz_m, "M4.6 local SLAM points").map(
|
||||||
|
(point) => vector(point, 3, "M4.6 local SLAM point") as [number, number, number],
|
||||||
|
),
|
||||||
|
localSlamSourceFrameCount: item.local_slam_source_frame_count === undefined
|
||||||
|
? undefined
|
||||||
|
: integer(item.local_slam_source_frame_count, "M4.6 local SLAM source frames"),
|
||||||
|
localSlamSourcePointCount: item.local_slam_source_point_count === undefined
|
||||||
|
? undefined
|
||||||
|
: integer(item.local_slam_source_point_count, "M4.6 local SLAM source points"),
|
||||||
cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined
|
cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined
|
||||||
? []
|
? []
|
||||||
: array(item.camera_projected_points_xyd, "M4.6 camera points").map(
|
: array(item.camera_projected_points_xyd, "M4.6 camera points").map(
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||||
|
type LaboratoryRecordedEvidenceViewerProfile,
|
||||||
|
} from "../observation/viewerProfile";
|
||||||
|
|
||||||
|
export type LaboratoryRecordedMediaMode = "video" | "camera" | null;
|
||||||
|
export type LaboratoryRecordedSpatialMode = "3d" | "plan" | null;
|
||||||
|
export type LaboratoryClassifiedSpatialMode = "none" | "overlay" | "replace-source";
|
||||||
|
|
||||||
|
export interface LaboratoryRecordedEvidenceVisibility {
|
||||||
|
mediaMode: LaboratoryRecordedMediaMode;
|
||||||
|
spatialMode: LaboratoryRecordedSpatialMode;
|
||||||
|
showMediaSemantic: boolean;
|
||||||
|
showSpatialSemantic: boolean;
|
||||||
|
showMediaPoints: boolean;
|
||||||
|
classifiedSpatialMode: LaboratoryClassifiedSpatialMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaboratoryRecordedEvidenceDemand {
|
||||||
|
sourceTimelineMetadata: true;
|
||||||
|
recordedVideo: boolean;
|
||||||
|
exactCameraFrame: boolean;
|
||||||
|
sourceSpatialPoints: boolean;
|
||||||
|
cameraPointOverlay: boolean;
|
||||||
|
selectedSemanticMask: boolean;
|
||||||
|
selectedSemanticPoints: boolean;
|
||||||
|
classifiedSpatial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate the already-visible M4 evidence composition into explicit data
|
||||||
|
* demand. The profile never starts a hidden point, semantic or camera channel
|
||||||
|
* merely because the selected LAB happens to publish that artifact.
|
||||||
|
*/
|
||||||
|
export function laboratoryRecordedEvidenceDemand(
|
||||||
|
visibility: LaboratoryRecordedEvidenceVisibility,
|
||||||
|
profile: LaboratoryRecordedEvidenceViewerProfile =
|
||||||
|
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||||
|
): LaboratoryRecordedEvidenceDemand {
|
||||||
|
if (profile.loadPolicy !== "visible-evidence-only") {
|
||||||
|
throw new Error("Unsupported LAB recorded evidence load policy");
|
||||||
|
}
|
||||||
|
const mediaVisible = visibility.mediaMode !== null;
|
||||||
|
const spatialVisible = visibility.spatialMode !== null;
|
||||||
|
return {
|
||||||
|
sourceTimelineMetadata: true,
|
||||||
|
recordedVideo: visibility.mediaMode === "video",
|
||||||
|
exactCameraFrame: visibility.mediaMode === "camera",
|
||||||
|
sourceSpatialPoints:
|
||||||
|
spatialVisible && visibility.classifiedSpatialMode !== "replace-source",
|
||||||
|
cameraPointOverlay: mediaVisible && visibility.showMediaPoints,
|
||||||
|
selectedSemanticMask: mediaVisible && visibility.showMediaSemantic,
|
||||||
|
selectedSemanticPoints: spatialVisible && visibility.showSpatialSemantic,
|
||||||
|
classifiedSpatial:
|
||||||
|
spatialVisible && visibility.classifiedSpatialMode !== "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -79,6 +79,83 @@ export interface VegetationRouteVideo {
|
|||||||
validFovMaskSha256: string | null;
|
validFovMaskSha256: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VegetationMixedRouteCase {
|
||||||
|
caseId: string;
|
||||||
|
phase: "rural" | "transition" | "urban";
|
||||||
|
sourceSequence: number;
|
||||||
|
sessionSeconds: number;
|
||||||
|
assets: Readonly<Record<"source" | "city" | "vegetation" | "tgs", string>>;
|
||||||
|
tgs: {
|
||||||
|
groundCells: number;
|
||||||
|
occupiedCells: number;
|
||||||
|
rejectedCells: number;
|
||||||
|
unobservedCells: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VegetationMixedRouteReview {
|
||||||
|
sourceId: "RAVNOVES004TREE";
|
||||||
|
sessionId: string;
|
||||||
|
packId: string;
|
||||||
|
frameCount: 10;
|
||||||
|
models: {
|
||||||
|
city: { name: string; inferenceFps: number; endToEndP95Ms: number };
|
||||||
|
vegetation: { name: string; latencyP95Ms: number };
|
||||||
|
tgs: { name: string; latencyP95Ms: number; cellSizeM: number; radiusM: number };
|
||||||
|
};
|
||||||
|
cases: readonly VegetationMixedRouteCase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VegetationRouteTgsAnchor {
|
||||||
|
sourceSequence: number;
|
||||||
|
slot: number;
|
||||||
|
currentPointsXyzM: readonly (readonly [number, number, number])[];
|
||||||
|
costmap: {
|
||||||
|
cellSizeM: 0.45;
|
||||||
|
centersXyM: readonly (readonly [number, number])[];
|
||||||
|
stateCodes: readonly number[];
|
||||||
|
zBoundsM: readonly (readonly [number | null, number | null])[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VegetationFullRouteLayer {
|
||||||
|
name: string;
|
||||||
|
resultId: string;
|
||||||
|
frameCount: 6830;
|
||||||
|
taxonomy: readonly VegetationVideoSemanticClass[];
|
||||||
|
inferenceFps: number;
|
||||||
|
latencyP95Ms: number;
|
||||||
|
peakReservedVramBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VegetationFullRouteReview {
|
||||||
|
sourceId: "RAVNOVES004TREE";
|
||||||
|
sessionId: "20260828T130511Z_viewer_live";
|
||||||
|
linkedRouteReviewResultId: string;
|
||||||
|
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0";
|
||||||
|
sourceJobInputSha256: string;
|
||||||
|
sourceStreamSha256: string;
|
||||||
|
recordedMediaSourceId: "recorded.camera.6a3945242828a038";
|
||||||
|
recordedMediaGenerationSha256: string;
|
||||||
|
frameCount: 6830;
|
||||||
|
width: 800;
|
||||||
|
height: 600;
|
||||||
|
timelineStartSeconds: number;
|
||||||
|
timelineEndSeconds: number;
|
||||||
|
timelineArtifact: {
|
||||||
|
sha256: string;
|
||||||
|
byteLength: number;
|
||||||
|
};
|
||||||
|
frameSourceTimesNs: readonly number[];
|
||||||
|
decodeRepair: {
|
||||||
|
repairedFrameCount: 1;
|
||||||
|
sequence: 6092;
|
||||||
|
method: "duplicate-previous-decoded-frame";
|
||||||
|
};
|
||||||
|
city: VegetationFullRouteLayer;
|
||||||
|
vegetation: VegetationFullRouteLayer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VegetationShadowResult {
|
export interface VegetationShadowResult {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
createdAtUtc: string;
|
createdAtUtc: string;
|
||||||
@@ -88,6 +165,8 @@ export interface VegetationShadowResult {
|
|||||||
routeCases: readonly VegetationVisualCase[];
|
routeCases: readonly VegetationVisualCase[];
|
||||||
validationCases: readonly VegetationVisualCase[];
|
validationCases: readonly VegetationVisualCase[];
|
||||||
routeVideo: VegetationRouteVideo | null;
|
routeVideo: VegetationRouteVideo | null;
|
||||||
|
routeReview: VegetationMixedRouteReview | null;
|
||||||
|
routeFullReview: VegetationFullRouteReview | null;
|
||||||
limitations: readonly string[];
|
limitations: readonly string[];
|
||||||
visualShadowReady: true;
|
visualShadowReady: true;
|
||||||
missionPolicyReadyForConfiguration: true;
|
missionPolicyReadyForConfiguration: true;
|
||||||
@@ -423,6 +502,359 @@ function routeVideoValue(value: unknown): VegetationRouteVideo | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mixedRouteReviewValue(
|
||||||
|
value: unknown,
|
||||||
|
resultId: string,
|
||||||
|
endpointRoot: string,
|
||||||
|
): VegetationMixedRouteReview | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const row = objectValue(value, "vegetation.route_review");
|
||||||
|
exact(row.source_id, "RAVNOVES004TREE", "vegetation.route_review.source_id");
|
||||||
|
exact(row.frame_count, 10, "vegetation.route_review.frame_count");
|
||||||
|
exact(row.ground_truth, false, "vegetation.route_review.ground_truth");
|
||||||
|
exact(
|
||||||
|
row.selection_policy,
|
||||||
|
"same-scene-camera-lidar-aligned-review-islands/v1",
|
||||||
|
"vegetation.route_review.selection_policy",
|
||||||
|
);
|
||||||
|
const packId = textValue(row.pack_id, "vegetation.route_review.pack_id");
|
||||||
|
if (!/^mixed-route-review-pack-[a-f0-9]{64}$/.test(packId)) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_review.pack_id: identity invalid.");
|
||||||
|
}
|
||||||
|
const models = objectValue(row.models, "vegetation.route_review.models");
|
||||||
|
const city = objectValue(models.city, "vegetation.route_review.models.city");
|
||||||
|
const vegetation = objectValue(models.vegetation, "vegetation.route_review.models.vegetation");
|
||||||
|
const tgsModel = objectValue(models.tgs, "vegetation.route_review.models.tgs");
|
||||||
|
exact(city.frames, 10, "vegetation.route_review.models.city.frames");
|
||||||
|
exact(vegetation.frames, 10, "vegetation.route_review.models.vegetation.frames");
|
||||||
|
exact(tgsModel.frames, 10, "vegetation.route_review.models.tgs.frames");
|
||||||
|
const cases = arrayValue(row.cases, "vegetation.route_review.cases").map((raw, index) => {
|
||||||
|
const item = objectValue(raw, `vegetation.route_review.cases[${index}]`);
|
||||||
|
const caseId = textValue(item.case_id, `vegetation.route_review.cases[${index}].case_id`);
|
||||||
|
if (caseId !== `route-${String(index + 1).padStart(2, "0")}`) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_review.case order changed.");
|
||||||
|
}
|
||||||
|
const phaseValue = item.phase;
|
||||||
|
if (phaseValue !== "rural" && phaseValue !== "transition" && phaseValue !== "urban") {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_review.phase changed.");
|
||||||
|
}
|
||||||
|
const phase: VegetationMixedRouteCase["phase"] = phaseValue;
|
||||||
|
const assets = objectValue(item.assets, `vegetation.route_review.cases[${index}].assets`);
|
||||||
|
const projected = Object.fromEntries(["source", "city", "vegetation", "tgs"].map((key) => {
|
||||||
|
const descriptor = objectValue(assets[key], `vegetation.route_review.assets.${key}`);
|
||||||
|
const path = textValue(descriptor.path, `vegetation.route_review.assets.${key}.path`);
|
||||||
|
const digest = textValue(descriptor.sha256, `vegetation.route_review.assets.${key}.sha256`);
|
||||||
|
if (!SHA256.test(digest) || !path.startsWith(`route-review/${caseId}/`)) {
|
||||||
|
throw new VegetationShadowContractError(`vegetation.route_review.assets.${key}: proof invalid.`);
|
||||||
|
}
|
||||||
|
return [key, `${endpointRoot}/${encodeURIComponent(resultId)}/assets/${path
|
||||||
|
.split("/").map(encodeURIComponent).join("/")}`];
|
||||||
|
})) as Record<"source" | "city" | "vegetation" | "tgs", string>;
|
||||||
|
const tgs = objectValue(item.tgs, `vegetation.route_review.cases[${index}].tgs`);
|
||||||
|
const groundCells = integerValue(tgs.ground_cells, "vegetation.route_review.tgs.ground");
|
||||||
|
const occupiedCells = integerValue(tgs.occupied_cells, "vegetation.route_review.tgs.occupied");
|
||||||
|
const rejectedCells = integerValue(tgs.rejected_cells, "vegetation.route_review.tgs.rejected");
|
||||||
|
const unobservedCells = integerValue(tgs.unobserved_cells, "vegetation.route_review.tgs.unobserved");
|
||||||
|
if (groundCells + occupiedCells + rejectedCells + unobservedCells !== 2244) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_review.tgs cell accounting changed.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
caseId,
|
||||||
|
phase,
|
||||||
|
sourceSequence: integerValue(item.source_sequence, "vegetation.route_review.source_sequence"),
|
||||||
|
sessionSeconds: numberValue(item.session_seconds, "vegetation.route_review.session_seconds"),
|
||||||
|
assets: projected,
|
||||||
|
tgs: { groundCells, occupiedCells, rejectedCells, unobservedCells },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (cases.length !== 10) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_review.cases: expected 10 aligned islands.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceId: "RAVNOVES004TREE",
|
||||||
|
sessionId: textValue(row.session_id, "vegetation.route_review.session_id"),
|
||||||
|
packId,
|
||||||
|
frameCount: 10,
|
||||||
|
models: {
|
||||||
|
city: {
|
||||||
|
name: textValue(city.name, "vegetation.route_review.models.city.name"),
|
||||||
|
inferenceFps: numberValue(city.inference_fps, "vegetation.route_review.models.city.fps"),
|
||||||
|
endToEndP95Ms: numberValue(city.end_to_end_p95_ms, "vegetation.route_review.models.city.p95"),
|
||||||
|
},
|
||||||
|
vegetation: {
|
||||||
|
name: textValue(vegetation.name, "vegetation.route_review.models.vegetation.name"),
|
||||||
|
latencyP95Ms: numberValue(vegetation.latency_p95_ms, "vegetation.route_review.models.vegetation.p95"),
|
||||||
|
},
|
||||||
|
tgs: {
|
||||||
|
name: textValue(tgsModel.name, "vegetation.route_review.models.tgs.name"),
|
||||||
|
latencyP95Ms: numberValue(tgsModel.latency_p95_ms, "vegetation.route_review.models.tgs.p95"),
|
||||||
|
cellSizeM: numberValue(tgsModel.cell_size_m, "vegetation.route_review.models.tgs.cell"),
|
||||||
|
radiusM: numberValue(tgsModel.radius_m, "vegetation.route_review.models.tgs.radius"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullRouteTaxonomyValue(
|
||||||
|
value: unknown,
|
||||||
|
label: string,
|
||||||
|
schema: string,
|
||||||
|
classCount: number,
|
||||||
|
): readonly VegetationVideoSemanticClass[] {
|
||||||
|
const taxonomy = objectValue(value, `${label}.taxonomy`);
|
||||||
|
exact(taxonomy.schema_version, schema, `${label}.taxonomy.schema`);
|
||||||
|
const classes = arrayValue(taxonomy.classes, `${label}.taxonomy.classes`).map(
|
||||||
|
(raw, expectedId): VegetationVideoSemanticClass => {
|
||||||
|
const item = objectValue(raw, `${label}.taxonomy[${expectedId}]`);
|
||||||
|
const classId = integerValue(item.class_id, `${label}.class_id[${expectedId}]`);
|
||||||
|
if (classId !== expectedId) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy order changed.`);
|
||||||
|
}
|
||||||
|
const color = arrayValue(item.color_rgb, `${label}.color[${expectedId}]`)
|
||||||
|
.map((channel, index) => integerValue(channel, `${label}.color[${expectedId}][${index}]`));
|
||||||
|
if (color.length !== 3 || color.some((channel) => channel > 255)) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy color invalid.`);
|
||||||
|
}
|
||||||
|
const disposition = item.disposition;
|
||||||
|
if (
|
||||||
|
disposition !== "labeled"
|
||||||
|
&& disposition !== "ambiguous"
|
||||||
|
&& disposition !== "prediction"
|
||||||
|
&& disposition !== "undefined"
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy disposition changed.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
label: textValue(item.label, `${label}.label[${expectedId}]`),
|
||||||
|
colorRgb: color as unknown as readonly [number, number, number],
|
||||||
|
disposition,
|
||||||
|
materialClass: item.material_class === null || item.material_class === undefined
|
||||||
|
? null
|
||||||
|
: textValue(item.material_class, `${label}.material[${expectedId}]`),
|
||||||
|
evidenceState: item.evidence_state === null || item.evidence_state === undefined
|
||||||
|
? null
|
||||||
|
: textValue(item.evidence_state, `${label}.evidence[${expectedId}]`),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (classes.length !== classCount) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: taxonomy size changed.`);
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullRouteLayerValue(
|
||||||
|
value: unknown,
|
||||||
|
layer: "city" | "vegetation",
|
||||||
|
): VegetationFullRouteLayer {
|
||||||
|
const label = `vegetation.route_full_review.layers.${layer}`;
|
||||||
|
const row = objectValue(value, label);
|
||||||
|
const resultId = textValue(row.result_id, `${label}.result_id`);
|
||||||
|
const identity = layer === "city"
|
||||||
|
? /^result-[a-f0-9]{64}$/
|
||||||
|
: /^lab-v1-ravnoves-video-ddrnet-[a-f0-9]{64}$/;
|
||||||
|
if (!identity.test(resultId)) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: identity invalid.`);
|
||||||
|
}
|
||||||
|
exact(row.frame_count, 6830, `${label}.frame_count`);
|
||||||
|
const archive = objectValue(row.mask_archive, `${label}.mask_archive`);
|
||||||
|
exact(
|
||||||
|
archive.path,
|
||||||
|
layer === "city" ? "video/eomt-semantic-masks.zip" : "video/ddrnet-semantic-masks.zip",
|
||||||
|
`${label}.mask_archive.path`,
|
||||||
|
);
|
||||||
|
const digest = textValue(archive.sha256, `${label}.mask_archive.sha256`);
|
||||||
|
if (!SHA256.test(digest)) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: archive digest invalid.`);
|
||||||
|
}
|
||||||
|
integerValue(archive.byte_length, `${label}.mask_archive.byte_length`);
|
||||||
|
return {
|
||||||
|
name: textValue(row.name, `${label}.name`),
|
||||||
|
resultId,
|
||||||
|
frameCount: 6830,
|
||||||
|
taxonomy: fullRouteTaxonomyValue(
|
||||||
|
row.taxonomy,
|
||||||
|
label,
|
||||||
|
layer === "city"
|
||||||
|
? "missioncore.recorded-eomt-taxonomy/v1"
|
||||||
|
: "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||||
|
layer === "city" ? 16 : 64,
|
||||||
|
),
|
||||||
|
inferenceFps: numberValue(row.inference_fps, `${label}.inference_fps`),
|
||||||
|
latencyP95Ms: numberValue(row.latency_p95_ms, `${label}.latency_p95_ms`),
|
||||||
|
peakReservedVramBytes: integerValue(
|
||||||
|
row.peak_reserved_vram_bytes,
|
||||||
|
`${label}.peak_reserved_vram_bytes`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullRouteReviewValue(value: unknown): VegetationFullRouteReview | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const row = objectValue(value, "vegetation.route_full_review");
|
||||||
|
exact(row.source_id, "RAVNOVES004TREE", "vegetation.route_full_review.source_id");
|
||||||
|
exact(
|
||||||
|
row.session_id,
|
||||||
|
"20260828T130511Z_viewer_live",
|
||||||
|
"vegetation.route_full_review.session_id",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
row.source_job_id,
|
||||||
|
"recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
"vegetation.route_full_review.source_job_id",
|
||||||
|
);
|
||||||
|
const linkedRouteReviewResultId = textValue(
|
||||||
|
row.linked_route_review_result_id,
|
||||||
|
"vegetation.route_full_review.linked_route_review_result_id",
|
||||||
|
);
|
||||||
|
if (!RESULT_ID.test(linkedRouteReviewResultId)) {
|
||||||
|
throw new VegetationShadowContractError(
|
||||||
|
"vegetation.route_full_review: linked route review identity invalid.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exact(row.frame_count, 6830, "vegetation.route_full_review.frame_count");
|
||||||
|
exact(row.width, 800, "vegetation.route_full_review.width");
|
||||||
|
exact(row.height, 600, "vegetation.route_full_review.height");
|
||||||
|
exact(row.ground_truth, false, "vegetation.route_full_review.ground_truth");
|
||||||
|
const sourceJobInputSha256 = textValue(
|
||||||
|
row.source_job_input_sha256,
|
||||||
|
"vegetation.route_full_review.source_job_input_sha256",
|
||||||
|
);
|
||||||
|
const sourceStreamSha256 = textValue(
|
||||||
|
row.source_stream_sha256,
|
||||||
|
"vegetation.route_full_review.source_stream_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
sourceJobInputSha256,
|
||||||
|
"eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
|
||||||
|
"vegetation.route_full_review.source_job_input_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
sourceStreamSha256,
|
||||||
|
"e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
"vegetation.route_full_review.source_stream_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
row.recorded_media_source_id,
|
||||||
|
"recorded.camera.6a3945242828a038",
|
||||||
|
"vegetation.route_full_review.recorded_media_source_id",
|
||||||
|
);
|
||||||
|
const recordedMediaGenerationSha256 = textValue(
|
||||||
|
row.recorded_media_generation_sha256,
|
||||||
|
"vegetation.route_full_review.recorded_media_generation_sha256",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
recordedMediaGenerationSha256,
|
||||||
|
"b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded",
|
||||||
|
"vegetation.route_full_review.recorded_media_generation_sha256",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!SHA256.test(sourceJobInputSha256)
|
||||||
|
|| !SHA256.test(sourceStreamSha256)
|
||||||
|
|| !SHA256.test(recordedMediaGenerationSha256)
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: source digest invalid.");
|
||||||
|
}
|
||||||
|
const timelineStartSeconds = numberValue(
|
||||||
|
row.timeline_start_seconds,
|
||||||
|
"vegetation.route_full_review.timeline_start_seconds",
|
||||||
|
);
|
||||||
|
const timelineEndSeconds = numberValue(
|
||||||
|
row.timeline_end_seconds,
|
||||||
|
"vegetation.route_full_review.timeline_end_seconds",
|
||||||
|
);
|
||||||
|
if (timelineEndSeconds <= timelineStartSeconds) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: timeline invalid.");
|
||||||
|
}
|
||||||
|
const timeline = objectValue(row.timeline, "vegetation.route_full_review.timeline");
|
||||||
|
exact(
|
||||||
|
timeline.path,
|
||||||
|
"video/frame-source-times-ns.bin",
|
||||||
|
"vegetation.route_full_review.timeline.path",
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
timeline.encoding,
|
||||||
|
"uint64-le-nanoseconds",
|
||||||
|
"vegetation.route_full_review.timeline.encoding",
|
||||||
|
);
|
||||||
|
exact(timeline.frame_count, 6830, "vegetation.route_full_review.timeline.frame_count");
|
||||||
|
const timelineSha256 = textValue(
|
||||||
|
timeline.sha256,
|
||||||
|
"vegetation.route_full_review.timeline.sha256",
|
||||||
|
);
|
||||||
|
if (!SHA256.test(timelineSha256)) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: timeline digest invalid.");
|
||||||
|
}
|
||||||
|
const timelineByteLength = integerValue(
|
||||||
|
timeline.byte_length,
|
||||||
|
"vegetation.route_full_review.timeline.byte_length",
|
||||||
|
);
|
||||||
|
exact(timelineByteLength, 6830 * 8, "vegetation.route_full_review.timeline.byte_length");
|
||||||
|
const decodeRepair = objectValue(
|
||||||
|
row.decode_repair,
|
||||||
|
"vegetation.route_full_review.decode_repair",
|
||||||
|
);
|
||||||
|
exact(decodeRepair.repaired_frame_count, 1, "vegetation.route_full_review.decode_repair.count");
|
||||||
|
exact(decodeRepair.sequence, 6092, "vegetation.route_full_review.decode_repair.sequence");
|
||||||
|
exact(
|
||||||
|
decodeRepair.method,
|
||||||
|
"duplicate-previous-decoded-frame",
|
||||||
|
"vegetation.route_full_review.decode_repair.method",
|
||||||
|
);
|
||||||
|
const repairProofs = objectValue(
|
||||||
|
decodeRepair.proofs,
|
||||||
|
"vegetation.route_full_review.decode_repair.proofs",
|
||||||
|
);
|
||||||
|
for (const [key, expectedPath] of Object.entries({
|
||||||
|
eomt: "proofs/decode_repair.json",
|
||||||
|
ddrnet: "proofs/ddrnet_decode_repair.json",
|
||||||
|
})) {
|
||||||
|
const proof = objectValue(
|
||||||
|
repairProofs[key],
|
||||||
|
`vegetation.route_full_review.decode_repair.proofs.${key}`,
|
||||||
|
);
|
||||||
|
exact(
|
||||||
|
proof.path,
|
||||||
|
expectedPath,
|
||||||
|
`vegetation.route_full_review.decode_repair.proofs.${key}.path`,
|
||||||
|
);
|
||||||
|
const digest = textValue(
|
||||||
|
proof.sha256,
|
||||||
|
`vegetation.route_full_review.decode_repair.proofs.${key}.sha256`,
|
||||||
|
);
|
||||||
|
if (!SHA256.test(digest)) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_full_review: repair proof invalid.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const layers = objectValue(row.layers, "vegetation.route_full_review.layers");
|
||||||
|
return {
|
||||||
|
sourceId: "RAVNOVES004TREE",
|
||||||
|
sessionId: "20260828T130511Z_viewer_live",
|
||||||
|
linkedRouteReviewResultId,
|
||||||
|
sourceJobId: "recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
sourceJobInputSha256,
|
||||||
|
sourceStreamSha256,
|
||||||
|
recordedMediaSourceId: "recorded.camera.6a3945242828a038",
|
||||||
|
recordedMediaGenerationSha256,
|
||||||
|
frameCount: 6830,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
timelineStartSeconds,
|
||||||
|
timelineEndSeconds,
|
||||||
|
timelineArtifact: { sha256: timelineSha256, byteLength: timelineByteLength },
|
||||||
|
frameSourceTimesNs: [],
|
||||||
|
decodeRepair: {
|
||||||
|
repairedFrameCount: 1,
|
||||||
|
sequence: 6092,
|
||||||
|
method: "duplicate-previous-decoded-frame",
|
||||||
|
},
|
||||||
|
city: fullRouteLayerValue(layers.city, "city"),
|
||||||
|
vegetation: fullRouteLayerValue(layers.vegetation, "vegetation"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseResult(
|
function parseResult(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
resultId: string,
|
resultId: string,
|
||||||
@@ -466,7 +898,12 @@ function parseResult(
|
|||||||
.map((item) => visualCaseValue(item, resultId, "ravnoves", endpointRoot));
|
.map((item) => visualCaseValue(item, resultId, "ravnoves", endpointRoot));
|
||||||
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
const validationCases = arrayValue(catalogs.goose, "vegetation.catalogs.goose")
|
||||||
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
|
.map((item) => visualCaseValue(item, resultId, "goose", endpointRoot));
|
||||||
if (routeCases.length !== 0 || validationCases.length !== 12) {
|
const routeReview = mixedRouteReviewValue(payload.route_review, resultId, endpointRoot);
|
||||||
|
const routeFullReview = fullRouteReviewValue(payload.route_full_review);
|
||||||
|
if (
|
||||||
|
routeCases.length !== 0
|
||||||
|
|| (routeReview || routeFullReview ? validationCases.length !== 0 : validationCases.length !== 12)
|
||||||
|
) {
|
||||||
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
|
throw new VegetationShadowContractError("vegetation.catalogs: ожидалось 12 truth-backed GOOSE случаев без route viewer.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -478,6 +915,8 @@ function parseResult(
|
|||||||
routeCases,
|
routeCases,
|
||||||
validationCases,
|
validationCases,
|
||||||
routeVideo: routeVideoValue(payload.route_video),
|
routeVideo: routeVideoValue(payload.route_video),
|
||||||
|
routeReview,
|
||||||
|
routeFullReview,
|
||||||
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
limitations: arrayValue(payload.limitations, "vegetation.limitations")
|
||||||
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
.map((item, index) => textValue(item, `vegetation.limitations[${index}]`)),
|
||||||
visualShadowReady: true,
|
visualShadowReady: true,
|
||||||
@@ -498,6 +937,95 @@ export function vegetationVideoMaskUrl(resultId: string, sequence: number): stri
|
|||||||
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`;
|
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/masks/${sequence}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function vegetationFullRouteMaskUrl(
|
||||||
|
resultId: string,
|
||||||
|
layer: "city" | "vegetation",
|
||||||
|
sequence: number,
|
||||||
|
): string {
|
||||||
|
if (
|
||||||
|
!RESULT_ID.test(resultId)
|
||||||
|
|| (layer !== "city" && layer !== "vegetation")
|
||||||
|
|| !Number.isInteger(sequence)
|
||||||
|
|| sequence < 0
|
||||||
|
|| sequence >= 6830
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation full-route mask identity недопустима.");
|
||||||
|
}
|
||||||
|
return `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-masks/${layer}/${sequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVegetationRouteTgsAnchor(
|
||||||
|
resultId: string,
|
||||||
|
sourceSequence: number,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||||
|
): Promise<VegetationRouteTgsAnchor> {
|
||||||
|
if (!RESULT_ID.test(resultId) || !Number.isInteger(sourceSequence) || sourceSequence < 1) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation TGS anchor identity недопустима.");
|
||||||
|
}
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
|
||||||
|
+ `/route-tgs-anchor/${sourceSequence}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new VegetationShadowContractError(`Vegetation TGS anchor недоступен: HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "vegetation.route_tgs_anchor");
|
||||||
|
exact(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.lab-v1-route-tgs-anchor/v1",
|
||||||
|
"vegetation.route_tgs_anchor.schema_version",
|
||||||
|
);
|
||||||
|
exact(payload.source_sequence, sourceSequence, "vegetation.route_tgs_anchor.source_sequence");
|
||||||
|
const pointValue = (value: unknown, label: string): readonly number[] => {
|
||||||
|
const point = arrayValue(value, label).map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||||
|
if (point.length !== 2 && point.length !== 3) {
|
||||||
|
throw new VegetationShadowContractError(`${label}: размер изменён.`);
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
};
|
||||||
|
const points = arrayValue(payload.current_points_xyz_m, "vegetation.route_tgs_anchor.points")
|
||||||
|
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.points[${index}]`));
|
||||||
|
const costmap = objectValue(payload.costmap, "vegetation.route_tgs_anchor.costmap");
|
||||||
|
exact(costmap.cell_size_m, 0.45, "vegetation.route_tgs_anchor.costmap.cell_size_m");
|
||||||
|
const centers = arrayValue(costmap.centers_xy_m, "vegetation.route_tgs_anchor.costmap.centers")
|
||||||
|
.map((value, index) => pointValue(value, `vegetation.route_tgs_anchor.costmap.centers[${index}]`));
|
||||||
|
const stateCodes = arrayValue(costmap.state_codes, "vegetation.route_tgs_anchor.costmap.states")
|
||||||
|
.map((value, index) => integerValue(value, `vegetation.route_tgs_anchor.costmap.states[${index}]`));
|
||||||
|
const zBounds = arrayValue(costmap.z_bounds_m, "vegetation.route_tgs_anchor.costmap.z_bounds")
|
||||||
|
.map((value, index) => {
|
||||||
|
const row = arrayValue(value, `vegetation.route_tgs_anchor.costmap.z_bounds[${index}]`);
|
||||||
|
if (row.length !== 2 || row.some((item) => item !== null && (typeof item !== "number" || !Number.isFinite(item)))) {
|
||||||
|
throw new VegetationShadowContractError("vegetation.route_tgs_anchor.costmap.z_bounds: контракт изменён.");
|
||||||
|
}
|
||||||
|
return row as readonly [number | null, number | null];
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
centers.length !== 2244
|
||||||
|
|| stateCodes.length !== 2244
|
||||||
|
|| zBounds.length !== 2244
|
||||||
|
|| stateCodes.some((value) => value > 3)
|
||||||
|
|| points.some((point) => point.length !== 3)
|
||||||
|
|| centers.some((point) => point.length !== 2)
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation TGS anchor shape изменён.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sourceSequence,
|
||||||
|
slot: integerValue(payload.slot, "vegetation.route_tgs_anchor.slot"),
|
||||||
|
currentPointsXyzM: points.map((point) => [point[0]!, point[1]!, point[2]!] as const),
|
||||||
|
costmap: {
|
||||||
|
cellSizeM: 0.45,
|
||||||
|
centersXyM: centers.map((point) => [point[0]!, point[1]!] as const),
|
||||||
|
stateCodes,
|
||||||
|
zBoundsM: zBounds,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchVegetationShadowResult(
|
export async function fetchVegetationShadowResult(
|
||||||
resultId: string,
|
resultId: string,
|
||||||
{
|
{
|
||||||
@@ -515,11 +1043,49 @@ export async function fetchVegetationShadowResult(
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
|
throw new VegetationShadowContractError(`Vegetation LAB недоступна: HTTP ${response.status}.`);
|
||||||
}
|
}
|
||||||
return parseResult(
|
const result = parseResult(
|
||||||
await response.json(),
|
await response.json(),
|
||||||
resultId,
|
resultId,
|
||||||
"/api/v1/laboratory/vegetation-shadow",
|
"/api/v1/laboratory/vegetation-shadow",
|
||||||
);
|
);
|
||||||
|
if (!result.routeFullReview) return result;
|
||||||
|
const timelineResponse = await fetcher(
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/octet-stream" }, signal },
|
||||||
|
);
|
||||||
|
if (!timelineResponse.ok) {
|
||||||
|
throw new VegetationShadowContractError(
|
||||||
|
`Vegetation LAB timeline недоступна: HTTP ${timelineResponse.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
timelineResponse.headers.get("etag")
|
||||||
|
!== `"${result.routeFullReview.timelineArtifact.sha256}"`
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline digest изменён.");
|
||||||
|
}
|
||||||
|
const timelinePayload = await timelineResponse.arrayBuffer();
|
||||||
|
if (timelinePayload.byteLength !== result.routeFullReview.timelineArtifact.byteLength) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline size изменён.");
|
||||||
|
}
|
||||||
|
const timelineView = new DataView(timelinePayload);
|
||||||
|
const frameSourceTimesNs = Array.from({ length: result.routeFullReview.frameCount }, (_, index) => {
|
||||||
|
const value = Number(timelineView.getBigUint64(index * 8, true));
|
||||||
|
if (!Number.isSafeInteger(value)) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline содержит unsafe time.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
frameSourceTimesNs[0] !== Math.round(result.routeFullReview.timelineStartSeconds * 1_000_000_000)
|
||||||
|
|| frameSourceTimesNs.some((time, index) => index > 0 && time <= frameSourceTimesNs[index - 1]!)
|
||||||
|
) {
|
||||||
|
throw new VegetationShadowContractError("Vegetation LAB timeline нарушена.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
routeFullReview: { ...result.routeFullReview, frameSourceTimesNs },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVegetationBenchmarkResult(
|
export async function fetchVegetationBenchmarkResult(
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { isUsableRecordedPlaybackRange } from "./recordedRerunLifecycle";
|
||||||
|
|
||||||
|
export function createReentrantViewerDisposer(
|
||||||
|
cleanupOnce: () => void,
|
||||||
|
releaseNativeViewer: () => void,
|
||||||
|
): () => void {
|
||||||
|
let cleanupComplete = false;
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
if (!cleanupComplete) {
|
||||||
|
cleanupComplete = true;
|
||||||
|
cleanupOnce();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// A deferred native start can resolve after a pre-ready stop. Reapply
|
||||||
|
// release at every boundary so it cannot reopen after React unmount.
|
||||||
|
releaseNativeViewer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveLiveViewerOwner {
|
||||||
|
release: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeLiveViewerOwner: ActiveLiveViewerOwner | null = null;
|
||||||
|
|
||||||
|
/** Own exactly one native live receiver per application document. */
|
||||||
|
export function claimExclusiveLiveViewer(release: () => void): () => void {
|
||||||
|
const owner = { release };
|
||||||
|
const previous = activeLiveViewerOwner;
|
||||||
|
activeLiveViewerOwner = owner;
|
||||||
|
previous?.release();
|
||||||
|
return () => {
|
||||||
|
if (activeLiveViewerOwner === owner) activeLiveViewerOwner = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiveRerunPresentationReady(
|
||||||
|
viewerStarted: boolean,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
backendActivitySequence: number | null,
|
||||||
|
): boolean {
|
||||||
|
return viewerStarted
|
||||||
|
&& Number.isSafeInteger(backendActivitySequence)
|
||||||
|
&& (backendActivitySequence ?? 0) > 0
|
||||||
|
&& isUsableRecordedPlaybackRange(rangeNs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveTimelineNeedsSynchronization(
|
||||||
|
followLive: boolean,
|
||||||
|
activeTimeline: string | null | undefined,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
): boolean {
|
||||||
|
return followLive
|
||||||
|
&& isUsableRecordedPlaybackRange(rangeNs)
|
||||||
|
&& activeTimeline !== "stream_time";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function liveRerunReceiverBindingIdentity(
|
||||||
|
sourceUrl: string,
|
||||||
|
liveStreamId: string | null,
|
||||||
|
followLive: boolean,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
followLive ? "live" : "recorded",
|
||||||
|
sourceUrl.trim(),
|
||||||
|
followLive ? liveStreamId?.trim() ?? "" : "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -327,6 +327,53 @@ export function verifyLiveViewerClientBuild(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover a recorded viewer whose lazy module disappeared during a frontend
|
||||||
|
* deployment. Live acquisition deliberately never reloads automatically, but
|
||||||
|
* a saved recording has no device-side authority to preserve and can safely
|
||||||
|
* move to the current immutable application build.
|
||||||
|
*/
|
||||||
|
export async function reloadRecordedViewerAfterStaleModuleFailure({
|
||||||
|
loadedUiBuildId,
|
||||||
|
signal,
|
||||||
|
fetcher = globalThis.fetch,
|
||||||
|
reload = () => window.location.reload(),
|
||||||
|
}: {
|
||||||
|
loadedUiBuildId: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
fetcher?: typeof globalThis.fetch;
|
||||||
|
reload?: () => void;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID ||
|
||||||
|
!HASHED_UI_BUILD_ID.test(loadedUiBuildId)
|
||||||
|
) return false;
|
||||||
|
try {
|
||||||
|
const response = await fetcher("/api/v1/viewer/client-contract", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-MissionCore-UI-Build": loadedUiBuildId,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (signal?.aborted || !response.ok) return false;
|
||||||
|
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
|
||||||
|
if (
|
||||||
|
!expectedUiBuildId ||
|
||||||
|
expectedUiBuildId === loadedUiBuildId ||
|
||||||
|
!HASHED_UI_BUILD_ID.test(expectedUiBuildId)
|
||||||
|
) return false;
|
||||||
|
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
|
||||||
|
reload();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startBuildMonitor(): void {
|
function startBuildMonitor(): void {
|
||||||
if (buildMonitorAbort || typeof window === "undefined") return;
|
if (buildMonitorAbort || typeof window === "undefined") return;
|
||||||
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { SceneSettings } from "../../sceneSettings";
|
||||||
|
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
|
||||||
|
import type { RerunPlaybackState, RerunViewportStatus } from "./viewerProfile";
|
||||||
|
|
||||||
|
const BUFFER_END_TOLERANCE_NS = 1_000_000;
|
||||||
|
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;
|
||||||
|
|
||||||
|
export const RECORDED_BASE_POINT_COLOR_KEY = "intensity|turbo|-";
|
||||||
|
|
||||||
|
export interface RecordedPlaybackBufferState {
|
||||||
|
bufferedEndNs: number | null;
|
||||||
|
expectedStartNs: number | null;
|
||||||
|
expectedEndNs: number | null;
|
||||||
|
bufferProgress: number | null;
|
||||||
|
fullyBuffered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
throw new Error("Unsafe recorded RRD byte length");
|
||||||
|
}
|
||||||
|
const transferBudgetMs = Math.ceil(
|
||||||
|
(byteLength / RECORDED_OPEN_MIN_BYTES_PER_SECOND) * 1_000,
|
||||||
|
);
|
||||||
|
return Math.min(
|
||||||
|
RECORDED_OPEN_MAX_TIMEOUT_MS,
|
||||||
|
Math.max(RECORDED_OPEN_MIN_TIMEOUT_MS, transferBudgetMs + RECORDED_OPEN_GRACE_MS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordedOpenWatchdog<T>({
|
||||||
|
byteLength,
|
||||||
|
schedule,
|
||||||
|
cancel,
|
||||||
|
onTimeout,
|
||||||
|
}: {
|
||||||
|
byteLength: number;
|
||||||
|
schedule: (callback: () => void, timeoutMs: number) => T;
|
||||||
|
cancel: (handle: T) => void;
|
||||||
|
onTimeout: () => void;
|
||||||
|
}): { arm: () => void; clear: () => void; pending: () => boolean } {
|
||||||
|
const timeoutMs = recordedOpenWatchdogTimeoutMs(byteLength);
|
||||||
|
let handle: T | null = null;
|
||||||
|
return {
|
||||||
|
arm() {
|
||||||
|
if (handle !== null) return;
|
||||||
|
handle = schedule(() => {
|
||||||
|
handle = null;
|
||||||
|
onTimeout();
|
||||||
|
}, timeoutMs);
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
if (handle === null) return;
|
||||||
|
cancel(handle);
|
||||||
|
handle = null;
|
||||||
|
},
|
||||||
|
pending: () => handle !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsableRecordedPlaybackRange(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
): rangeNs is { min: number; max: number } {
|
||||||
|
return Boolean(
|
||||||
|
rangeNs
|
||||||
|
&& Number.isFinite(rangeNs.min)
|
||||||
|
&& Number.isFinite(rangeNs.max)
|
||||||
|
&& rangeNs.max >= rangeNs.min,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedPlaybackBufferState(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
expectedTimelineEndSeconds?: number,
|
||||||
|
expectedTimelineStartSeconds?: number,
|
||||||
|
): RecordedPlaybackBufferState {
|
||||||
|
const usableRange = isUsableRecordedPlaybackRange(rangeNs) ? rangeNs : null;
|
||||||
|
const bufferedEndNs = usableRange?.max ?? null;
|
||||||
|
if (expectedTimelineEndSeconds === undefined) {
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs: null,
|
||||||
|
expectedEndNs: null,
|
||||||
|
bufferProgress: null,
|
||||||
|
fullyBuffered: usableRange !== null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(expectedTimelineEndSeconds) || expectedTimelineEndSeconds < 0) {
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs: null,
|
||||||
|
expectedEndNs: null,
|
||||||
|
bufferProgress: null,
|
||||||
|
fullyBuffered: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedEndNs = expectedTimelineEndSeconds * 1_000_000_000;
|
||||||
|
const expectedStartNs = expectedTimelineStartSeconds === undefined
|
||||||
|
? 0
|
||||||
|
: expectedTimelineStartSeconds * 1_000_000_000;
|
||||||
|
if (
|
||||||
|
!Number.isFinite(expectedEndNs)
|
||||||
|
|| !Number.isFinite(expectedStartNs)
|
||||||
|
|| expectedStartNs < 0
|
||||||
|
|| expectedEndNs < expectedStartNs
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs: null,
|
||||||
|
expectedEndNs: null,
|
||||||
|
bufferProgress: null,
|
||||||
|
fullyBuffered: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const fullyBuffered = usableRange !== null
|
||||||
|
&& usableRange.min <= expectedStartNs + BUFFER_END_TOLERANCE_NS
|
||||||
|
&& usableRange.max >= expectedEndNs - BUFFER_END_TOLERANCE_NS;
|
||||||
|
const expectedDurationNs = expectedEndNs - expectedStartNs;
|
||||||
|
const bufferProgress = bufferedEndNs === null
|
||||||
|
? 0
|
||||||
|
: expectedDurationNs <= 0
|
||||||
|
? (fullyBuffered ? 1 : 0)
|
||||||
|
: Math.min(1, Math.max(0, (bufferedEndNs - expectedStartNs) / expectedDurationNs));
|
||||||
|
return {
|
||||||
|
bufferedEndNs,
|
||||||
|
expectedStartNs,
|
||||||
|
expectedEndNs,
|
||||||
|
bufferProgress,
|
||||||
|
fullyBuffered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecordedPlaybackReady(
|
||||||
|
viewerStarted: boolean,
|
||||||
|
artifactVerified: boolean,
|
||||||
|
buffer: RecordedPlaybackBufferState,
|
||||||
|
): boolean {
|
||||||
|
return viewerStarted && artifactVerified && buffer.bufferedEndNs !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedPlaybackRangeWhenReady(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
buffer: RecordedPlaybackBufferState,
|
||||||
|
artifactVerified: boolean,
|
||||||
|
): { min: number; max: number } | null {
|
||||||
|
if (
|
||||||
|
!artifactVerified
|
||||||
|
|| !buffer.fullyBuffered
|
||||||
|
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||||
|
) return null;
|
||||||
|
return {
|
||||||
|
min: buffer.expectedStartNs === null
|
||||||
|
? rangeNs.min
|
||||||
|
: Math.max(rangeNs.min, buffer.expectedStartNs),
|
||||||
|
max: buffer.expectedEndNs === null
|
||||||
|
? rangeNs.max
|
||||||
|
: Math.min(rangeNs.max, buffer.expectedEndNs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecordedPlaybackPresentationReady(
|
||||||
|
status: RerunViewportStatus,
|
||||||
|
playback: RerunPlaybackState | null,
|
||||||
|
): boolean {
|
||||||
|
return status === "ready"
|
||||||
|
&& playback?.fullyBuffered === true
|
||||||
|
&& isUsableRecordedPlaybackRange(playback.rangeNs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rerunPresentationStatus(
|
||||||
|
status: RerunViewportStatus,
|
||||||
|
_gate: RecordedAdmissionPhase,
|
||||||
|
recorded: boolean,
|
||||||
|
): RerunViewportStatus {
|
||||||
|
if (!recorded) return status;
|
||||||
|
return status === "error" ? "error" : status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecordedPlaybackFullyBuffered(
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
expectedTimelineEndSeconds?: number,
|
||||||
|
): boolean {
|
||||||
|
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attemptRecordedAutoplay(
|
||||||
|
seekToStart: () => void,
|
||||||
|
startPlaying: () => void,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
seekToStart();
|
||||||
|
startPlaying();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordedAutoplayGate(): {
|
||||||
|
attempt: (
|
||||||
|
viewerStarted: boolean,
|
||||||
|
fullyBuffered: boolean,
|
||||||
|
presentationReady: boolean,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
seekToStart: (startNs: number) => void,
|
||||||
|
startPlaying: () => void,
|
||||||
|
preferredStartNs?: number,
|
||||||
|
) => boolean;
|
||||||
|
attempted: () => boolean;
|
||||||
|
} {
|
||||||
|
let consumed = false;
|
||||||
|
return {
|
||||||
|
attempt(
|
||||||
|
viewerStarted,
|
||||||
|
fullyBuffered,
|
||||||
|
presentationReady,
|
||||||
|
rangeNs,
|
||||||
|
seekToStart,
|
||||||
|
startPlaying,
|
||||||
|
preferredStartNs,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
consumed
|
||||||
|
|| !viewerStarted
|
||||||
|
|| !fullyBuffered
|
||||||
|
|| !presentationReady
|
||||||
|
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||||
|
) return false;
|
||||||
|
consumed = true;
|
||||||
|
const startNs = Number.isFinite(preferredStartNs)
|
||||||
|
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
|
||||||
|
: rangeNs.min;
|
||||||
|
return attemptRecordedAutoplay(
|
||||||
|
() => seekToStart(startNs),
|
||||||
|
startPlaying,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
attempted: () => consumed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPublishRecordedPlaybackController(
|
||||||
|
readyToRender: boolean,
|
||||||
|
presentationGate: RecordedAdmissionPhase,
|
||||||
|
): boolean {
|
||||||
|
return readyToRender && presentationGate === "ready";
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { RecordedAdmissionPhase } from "./recordedSessionAdmission";
|
||||||
|
|
||||||
|
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
|
||||||
|
|
||||||
|
export interface RerunPlaybackState {
|
||||||
|
recordingId: string;
|
||||||
|
timeline: string;
|
||||||
|
rangeNs: { min: number; max: number } | null;
|
||||||
|
currentNs: number;
|
||||||
|
playing: boolean;
|
||||||
|
bufferedEndNs: number | null;
|
||||||
|
expectedStartNs: number | null;
|
||||||
|
expectedEndNs: number | null;
|
||||||
|
bufferProgress: number | null;
|
||||||
|
fullyBuffered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RerunPlaybackController {
|
||||||
|
seek: (timeNs: number) => void;
|
||||||
|
setPlaying: (playing: boolean) => void;
|
||||||
|
jumpToEnd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordedPerceptionLayers {
|
||||||
|
enabled: boolean;
|
||||||
|
detections2d: boolean;
|
||||||
|
segmentation: boolean;
|
||||||
|
cuboids3d: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordedRrdArtifactDescriptor {
|
||||||
|
sourceUrl: string;
|
||||||
|
viewerSourceUrl: string;
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native live receiver owns one acquisition lineage and never exposes
|
||||||
|
* recorded transport or autoplay policy.
|
||||||
|
*/
|
||||||
|
export interface LiveAcquisitionRerunProfile {
|
||||||
|
kind: "live-acquisition";
|
||||||
|
clock: "stream_time";
|
||||||
|
sourceUrl: string;
|
||||||
|
liveActivitySequence: number | null;
|
||||||
|
liveStreamId: string | null;
|
||||||
|
liveRecoveryAuthorityIdentity: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sealed session is progressively presentable, while its shared playback
|
||||||
|
* controls remain fenced by the aggregate session admission gate.
|
||||||
|
*/
|
||||||
|
export interface RecordedSessionRerunProfile {
|
||||||
|
kind: "recorded-session";
|
||||||
|
clock: "session_time";
|
||||||
|
sourceUrl: string;
|
||||||
|
artifact: RecordedRrdArtifactDescriptor | null;
|
||||||
|
autoplayWhenReady: boolean;
|
||||||
|
presentationGate: RecordedAdmissionPhase;
|
||||||
|
expectedTimelineStartSeconds?: number;
|
||||||
|
expectedTimelineEndSeconds?: number;
|
||||||
|
initialPlaybackStartSeconds?: number;
|
||||||
|
view: RecordedRerunView;
|
||||||
|
viewResetGeneration: 0 | 1;
|
||||||
|
followTrajectory: boolean;
|
||||||
|
perceptionLayers: RecordedPerceptionLayers;
|
||||||
|
perceptionRetryGeneration: number;
|
||||||
|
lockPerceptionCameraInteraction: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LAB recorded evidence is not a native Rerun mode. It owns a source-sequence
|
||||||
|
* clock and composes the existing sealed fMP4 and retained spatial primitives.
|
||||||
|
*/
|
||||||
|
export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||||
|
kind: "lab-recorded-evidence";
|
||||||
|
clock: "source-sequence";
|
||||||
|
cameraTransport: "generation-bound-fmp4";
|
||||||
|
spatialTransport: "bounded-sealed-artifacts";
|
||||||
|
loadPolicy: "visible-evidence-only";
|
||||||
|
workerRequired: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RerunViewerProfile =
|
||||||
|
| LiveAcquisitionRerunProfile
|
||||||
|
| RecordedSessionRerunProfile;
|
||||||
|
|
||||||
|
export type ObservationViewerProfile =
|
||||||
|
| RerunViewerProfile
|
||||||
|
| LaboratoryRecordedEvidenceViewerProfile;
|
||||||
|
|
||||||
|
export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||||
|
kind: "lab-recorded-evidence",
|
||||||
|
clock: "source-sequence",
|
||||||
|
cameraTransport: "generation-bound-fmp4",
|
||||||
|
spatialTransport: "bounded-sealed-artifacts",
|
||||||
|
loadPolicy: "visible-evidence-only",
|
||||||
|
workerRequired: false,
|
||||||
|
} satisfies LaboratoryRecordedEvidenceViewerProfile);
|
||||||
|
|
||||||
|
export function liveAcquisitionRerunProfile(
|
||||||
|
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||||
|
): LiveAcquisitionRerunProfile {
|
||||||
|
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedSessionRerunProfile(
|
||||||
|
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||||
|
): RecordedSessionRerunProfile {
|
||||||
|
return { kind: "recorded-session", clock: "session_time", ...input };
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
RecordedCameraAdmissionState,
|
RecordedCameraAdmissionState,
|
||||||
} from "../core/observation/recordedSessionAdmission";
|
} from "../core/observation/recordedSessionAdmission";
|
||||||
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
||||||
|
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
|
||||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||||
import {
|
import {
|
||||||
RerunViewport,
|
RerunViewport,
|
||||||
@@ -46,7 +47,6 @@ function statusTone(status: CapabilityStatus): "success" | "accent" | "warning"
|
|||||||
if (status === "contract") return "warning";
|
if (status === "contract") return "warning";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||||
return (
|
return (
|
||||||
<div className="feature-inventory">
|
<div className="feature-inventory">
|
||||||
@@ -76,7 +76,6 @@ function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||||
return (
|
return (
|
||||||
<section className="workspace-lead workspace-lead--compact">
|
<section className="workspace-lead workspace-lead--compact">
|
||||||
@@ -89,7 +88,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||||
return (
|
return (
|
||||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||||
@@ -181,7 +179,7 @@ function SpatialWorkspace({
|
|||||||
);
|
);
|
||||||
const recordedPerceptionSupported =
|
const recordedPerceptionSupported =
|
||||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||||
const recordedPerceptionReady = recordedSource && perceptionLoad.phase === "ready";
|
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||||
const recordedPerceptionEnabled =
|
const recordedPerceptionEnabled =
|
||||||
showDetections2d || showSegmentation || showCuboids3d;
|
showDetections2d || showSegmentation || showCuboids3d;
|
||||||
// The native recorded camera remains the authoritative original. Only 2D
|
// The native recorded camera remains the authoritative original. Only 2D
|
||||||
@@ -261,7 +259,7 @@ function SpatialWorkspace({
|
|||||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||||
if (!recordedSessionAdmission) return false;
|
if (!recordedSessionAdmission) return false;
|
||||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||||
}, [recordedSessionAdmission]);
|
}, [recordedSessionAdmission]);
|
||||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||||
const onPlaybackChange = useCallback(
|
const onPlaybackChange = useCallback(
|
||||||
@@ -381,6 +379,35 @@ function SpatialWorkspace({
|
|||||||
: presentedViewerStatus === "error"
|
: presentedViewerStatus === "error"
|
||||||
? "danger"
|
? "danger"
|
||||||
: "neutral";
|
: "neutral";
|
||||||
|
const rerunViewerProfile = recordedSource
|
||||||
|
? recordedSessionRerunProfile({
|
||||||
|
sourceUrl,
|
||||||
|
artifact: recordedReplay,
|
||||||
|
autoplayWhenReady: true,
|
||||||
|
presentationGate: recordedSessionGate,
|
||||||
|
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
|
||||||
|
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
|
||||||
|
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
|
||||||
|
view: "spatial",
|
||||||
|
viewResetGeneration: recordedViewResetGeneration,
|
||||||
|
followTrajectory: followRecordedTrajectory,
|
||||||
|
perceptionLayers: {
|
||||||
|
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
||||||
|
detections2d: showDetections2d,
|
||||||
|
segmentation: showSegmentation,
|
||||||
|
cuboids3d: showCuboids3d,
|
||||||
|
},
|
||||||
|
perceptionRetryGeneration,
|
||||||
|
lockPerceptionCameraInteraction: unifiedPerception,
|
||||||
|
})
|
||||||
|
: liveAcquisitionRerunProfile({
|
||||||
|
sourceUrl,
|
||||||
|
liveActivitySequence: livePresentationActivitySequence,
|
||||||
|
liveStreamId: state?.spatialSource?.id ?? null,
|
||||||
|
liveRecoveryAuthorityIdentity: streamActive
|
||||||
|
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -405,7 +432,7 @@ function SpatialWorkspace({
|
|||||||
variant={detections2dActive ? "primary" : "secondary"}
|
variant={detections2dActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="target" />}
|
icon={<Icon name="target" />}
|
||||||
aria-pressed={detections2dActive}
|
aria-pressed={detections2dActive}
|
||||||
disabled={recordedSource && !recordedPerceptionReady}
|
disabled={recordedPerceptionLoading}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowDetections2d((current) => !current)
|
? setShowDetections2d((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
@@ -420,7 +447,7 @@ function SpatialWorkspace({
|
|||||||
variant={segmentationActive ? "primary" : "secondary"}
|
variant={segmentationActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="image" />}
|
icon={<Icon name="image" />}
|
||||||
aria-pressed={segmentationActive}
|
aria-pressed={segmentationActive}
|
||||||
disabled={recordedSource && !recordedPerceptionReady}
|
disabled={recordedPerceptionLoading}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowSegmentation((current) => !current)
|
? setShowSegmentation((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
@@ -435,7 +462,7 @@ function SpatialWorkspace({
|
|||||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||||
icon={<Icon name="apps" />}
|
icon={<Icon name="apps" />}
|
||||||
aria-pressed={cuboids3dActive}
|
aria-pressed={cuboids3dActive}
|
||||||
disabled={recordedSource && !recordedPerceptionReady}
|
disabled={recordedPerceptionLoading}
|
||||||
onClick={() => recordedSource
|
onClick={() => recordedSource
|
||||||
? setShowCuboids3d((current) => !current)
|
? setShowCuboids3d((current) => !current)
|
||||||
: onLivePerceptionLayersChange({
|
: onLivePerceptionLayersChange({
|
||||||
@@ -489,35 +516,8 @@ function SpatialWorkspace({
|
|||||||
>
|
>
|
||||||
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
{sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||||
<RerunViewport
|
<RerunViewport
|
||||||
sourceUrl={sourceUrl}
|
profile={rerunViewerProfile}
|
||||||
recordedArtifact={recordedSource ? recordedReplay : null}
|
|
||||||
followLive={liveRerunSource}
|
|
||||||
liveActivitySequence={livePresentationActivitySequence}
|
|
||||||
liveStreamId={state?.spatialSource?.id}
|
|
||||||
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
|
|
||||||
autoplayWhenReady={recordedSource}
|
|
||||||
presentationGate={recordedSessionGate}
|
|
||||||
expectedTimelineStartSeconds={recordedSource
|
|
||||||
? state?.observationTimeline?.range?.startSeconds
|
|
||||||
: undefined}
|
|
||||||
expectedTimelineEndSeconds={recordedSource
|
|
||||||
? state?.observationTimeline?.range?.endSeconds
|
|
||||||
: undefined}
|
|
||||||
initialPlaybackStartSeconds={initialRecordedPlaybackStartSeconds}
|
|
||||||
sceneSettings={sceneSettings}
|
sceneSettings={sceneSettings}
|
||||||
recordedViewResetGeneration={recordedViewResetGeneration}
|
|
||||||
recordedFollowTrajectory={followRecordedTrajectory}
|
|
||||||
recordedPerceptionLayers={{
|
|
||||||
enabled:
|
|
||||||
recordedPerceptionSupported &&
|
|
||||||
recordedPerceptionReady &&
|
|
||||||
recordedPerceptionEnabled,
|
|
||||||
detections2d: showDetections2d,
|
|
||||||
segmentation: showSegmentation,
|
|
||||||
cuboids3d: showCuboids3d,
|
|
||||||
}}
|
|
||||||
recordedPerceptionRetryGeneration={perceptionRetryGeneration}
|
|
||||||
lockPerceptionCameraInteraction={unifiedPerception}
|
|
||||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||||
onPointColorLoadChange={onPointColorLoadChange}
|
onPointColorLoadChange={onPointColorLoadChange}
|
||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
@@ -905,7 +905,7 @@ function CamerasWorkspace({
|
|||||||
if (!recordedReplay) return true;
|
if (!recordedReplay) return true;
|
||||||
if (!recordedSessionAdmission) return false;
|
if (!recordedSessionAdmission) return false;
|
||||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
|
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||||
}, [recordedReplay, recordedSessionAdmission]);
|
}, [recordedReplay, recordedSessionAdmission]);
|
||||||
return (
|
return (
|
||||||
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
|
||||||
|
|||||||
@@ -215,12 +215,24 @@ export function M49TgsFullShadowEvidence({
|
|||||||
controlLabel: semanticOverride.controlLabel ?? "ПРИРОДА · DDRNet",
|
controlLabel: semanticOverride.controlLabel ?? "ПРИРОДА · DDRNet",
|
||||||
}] : []),
|
}] : []),
|
||||||
], [semantic, semanticOverride]);
|
], [semantic, semanticOverride]);
|
||||||
|
const spatialSemantic = useMemo<M4ReplayThreatSemanticLayer | undefined>(() => (
|
||||||
|
semantic ? {
|
||||||
|
id: "spatial-urban",
|
||||||
|
controlLabel: "SEMANTICS",
|
||||||
|
resultId: semantic.resultId,
|
||||||
|
spatialResultId: semantic.resultId,
|
||||||
|
taxonomy: semantic.taxonomy,
|
||||||
|
label: "EoMT Cityscapes semantic · point-aligned E47",
|
||||||
|
maskAriaLabel: "EoMT urban semantic prediction",
|
||||||
|
} : undefined
|
||||||
|
), [semantic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<M4ReplayThreatVisual
|
<M4ReplayThreatVisual
|
||||||
resultId={result.source.linkedVisualResultId}
|
resultId={result.source.linkedVisualResultId}
|
||||||
semanticLayers={semanticLayers}
|
semanticLayers={semanticLayers}
|
||||||
|
spatialSemantic={spatialSemantic}
|
||||||
initialSemanticLayerId={semanticOverride ? "vegetation" : "urban"}
|
initialSemanticLayerId={semanticOverride ? "vegetation" : "urban"}
|
||||||
showReviewAnchorBoxes={false}
|
showReviewAnchorBoxes={false}
|
||||||
reviewLabel="4 489 source-paced TGS frames"
|
reviewLabel="4 489 source-paced TGS frames"
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Select,
|
Select,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
SplitPane,
|
|
||||||
type SplitPaneOrientation,
|
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||||
|
import {
|
||||||
|
CanonicalRecordedLabReplay,
|
||||||
|
useCanonicalRecordedLabReplayState,
|
||||||
|
} from "../../components/laboratory/CanonicalRecordedLabReplay";
|
||||||
import {
|
import {
|
||||||
LaboratoryMetricEvidenceScene,
|
LaboratoryMetricEvidenceScene,
|
||||||
type LaboratoryMetricCellEvidence,
|
type LaboratoryMetricCellEvidence,
|
||||||
@@ -35,6 +45,7 @@ import {
|
|||||||
type E47SemanticClass,
|
type E47SemanticClass,
|
||||||
type E47SemanticTimelineFrame,
|
type E47SemanticTimelineFrame,
|
||||||
} from "../../core/laboratory/e47SemanticSlam";
|
} from "../../core/laboratory/e47SemanticSlam";
|
||||||
|
import { laboratoryRecordedEvidenceDemand } from "../../core/laboratory/recordedEvidenceProfile";
|
||||||
import type {
|
import type {
|
||||||
M4ThreatCameraProposal,
|
M4ThreatCameraProposal,
|
||||||
M4ThreatTimelineFrame,
|
M4ThreatTimelineFrame,
|
||||||
@@ -52,8 +63,6 @@ import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
|||||||
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
|
import { buildM4StaticObstacleBoxes } from "./m4StaticObstacleBoxes";
|
||||||
|
|
||||||
type M4ThreatMediaMode = "video" | "camera";
|
type M4ThreatMediaMode = "video" | "camera";
|
||||||
type M4ThreatMediaSelection = M4ThreatMediaMode | "none";
|
|
||||||
type M4ThreatSpatialSelection = LaboratoryMetricSceneMode | "none";
|
|
||||||
|
|
||||||
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||||
if (proposal.threatDecision === "threat") return "danger";
|
if (proposal.threatDecision === "threat") return "danger";
|
||||||
@@ -144,6 +153,7 @@ export interface M4ReplayClassifiedSpatialLayer {
|
|||||||
label: string;
|
label: string;
|
||||||
pointLayerLabel: string;
|
pointLayerLabel: string;
|
||||||
cellLayerLabel: string;
|
cellLayerLabel: string;
|
||||||
|
cellLayerAvailable?: boolean;
|
||||||
expectedAtSequence: boolean;
|
expectedAtSequence: boolean;
|
||||||
frame: M4ReplayClassifiedSpatialFrame | null;
|
frame: M4ReplayClassifiedSpatialFrame | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -158,6 +168,7 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId,
|
resultId,
|
||||||
semantic,
|
semantic,
|
||||||
semanticLayers,
|
semanticLayers,
|
||||||
|
spatialSemantic,
|
||||||
initialSemanticLayerId,
|
initialSemanticLayerId,
|
||||||
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
reviewAnchors = EMPTY_REVIEW_ANCHORS,
|
||||||
showReviewAnchorBoxes = true,
|
showReviewAnchorBoxes = true,
|
||||||
@@ -168,11 +179,15 @@ export function M4ReplayThreatVisual({
|
|||||||
classifiedSpatialLayer,
|
classifiedSpatialLayer,
|
||||||
showReferenceMediaLayers = true,
|
showReferenceMediaLayers = true,
|
||||||
showSpatialOverlaySummary = true,
|
showSpatialOverlaySummary = true,
|
||||||
|
playbackTransport = "epoch-stream",
|
||||||
|
spatialPlaybackTransport = "auto",
|
||||||
|
recoverTimestampStalls = false,
|
||||||
onActiveSequenceChange,
|
onActiveSequenceChange,
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
semantic?: M4ReplayThreatSemanticLayer;
|
semantic?: M4ReplayThreatSemanticLayer;
|
||||||
semanticLayers?: readonly M4ReplayThreatSemanticLayer[];
|
semanticLayers?: readonly M4ReplayThreatSemanticLayer[];
|
||||||
|
spatialSemantic?: M4ReplayThreatSemanticLayer;
|
||||||
initialSemanticLayerId?: string;
|
initialSemanticLayerId?: string;
|
||||||
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
reviewAnchors?: readonly M4ReplayThreatReviewAnchor[];
|
||||||
showReviewAnchorBoxes?: boolean;
|
showReviewAnchorBoxes?: boolean;
|
||||||
@@ -183,12 +198,26 @@ export function M4ReplayThreatVisual({
|
|||||||
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
classifiedSpatialLayer?: M4ReplayClassifiedSpatialLayer;
|
||||||
showReferenceMediaLayers?: boolean;
|
showReferenceMediaLayers?: boolean;
|
||||||
showSpatialOverlaySummary?: boolean;
|
showSpatialOverlaySummary?: boolean;
|
||||||
|
playbackTransport?: "segmented" | "epoch-stream";
|
||||||
|
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||||
|
recoverTimestampStalls?: boolean;
|
||||||
onActiveSequenceChange?: (sequence: number | null) => void;
|
onActiveSequenceChange?: (sequence: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [mediaMode, setMediaMode] = useState<M4ThreatMediaMode | null>("video");
|
const {
|
||||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode | null>(
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
splitView,
|
||||||
|
splitPrimarySize,
|
||||||
|
splitOrientation,
|
||||||
|
expanded,
|
||||||
|
onMediaModeChange: handleMediaModeChange,
|
||||||
|
onSpatialModeChange: handleSpatialModeChange,
|
||||||
|
onSplitPrimarySizeChange: setSplitPrimarySize,
|
||||||
|
onExpandedChange: setExpanded,
|
||||||
|
} = useCanonicalRecordedLabReplayState<M4ThreatMediaMode, LaboratoryMetricSceneMode>({
|
||||||
|
initialMediaMode: "video",
|
||||||
initialSpatialMode,
|
initialSpatialMode,
|
||||||
);
|
});
|
||||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||||
@@ -197,13 +226,6 @@ export function M4ReplayThreatVisual({
|
|||||||
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
const [showSpatialSemantic, setShowSpatialSemantic] = useState(true);
|
||||||
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
const [showMediaPoints, setShowMediaPoints] = useState(false);
|
||||||
const [showStaticObstacles, setShowStaticObstacles] = useState(true);
|
const [showStaticObstacles, setShowStaticObstacles] = useState(true);
|
||||||
const [splitPrimarySize, setSplitPrimarySize] = useState(50);
|
|
||||||
const [splitOrientation, setSplitOrientation] = useState<SplitPaneOrientation>(() => (
|
|
||||||
typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches
|
|
||||||
? "horizontal"
|
|
||||||
: "vertical"
|
|
||||||
));
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
|
const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0);
|
||||||
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
|
const availableSemanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(
|
||||||
() => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [],
|
() => semanticLayers?.length ? semanticLayers : semantic ? [semantic] : [],
|
||||||
@@ -234,6 +256,28 @@ export function M4ReplayThreatVisual({
|
|||||||
const activeSemantic = availableSemanticLayers.find(
|
const activeSemantic = availableSemanticLayers.find(
|
||||||
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
|
(layer, index) => (layer.id ?? `${layer.resultId}:${index}`) === selectedSemanticLayerId,
|
||||||
) ?? availableSemanticLayers[0];
|
) ?? availableSemanticLayers[0];
|
||||||
|
const activeSpatialSemantic = spatialSemantic ?? activeSemantic;
|
||||||
|
const evidenceDemand = useMemo(() => laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode,
|
||||||
|
spatialMode,
|
||||||
|
showMediaSemantic: Boolean(activeSemantic) && showMediaSemantic,
|
||||||
|
showSpatialSemantic: Boolean(activeSpatialSemantic) && showSpatialSemantic,
|
||||||
|
showMediaPoints,
|
||||||
|
classifiedSpatialMode: !classifiedSpatialLayer || classifiedSpatialLayer.cellLayerAvailable === false
|
||||||
|
? "none"
|
||||||
|
: classifiedSpatialLayer.replacePointCloud
|
||||||
|
? "replace-source"
|
||||||
|
: "overlay",
|
||||||
|
}), [
|
||||||
|
activeSemantic,
|
||||||
|
activeSpatialSemantic,
|
||||||
|
classifiedSpatialLayer,
|
||||||
|
mediaMode,
|
||||||
|
showMediaPoints,
|
||||||
|
showMediaSemantic,
|
||||||
|
showSpatialSemantic,
|
||||||
|
spatialMode,
|
||||||
|
]);
|
||||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||||
const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
|
const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot);
|
||||||
const playbackRange = useMemo(() => metadata.timeline ? ({
|
const playbackRange = useMemo(() => metadata.timeline ? ({
|
||||||
@@ -241,7 +285,7 @@ export function M4ReplayThreatVisual({
|
|||||||
endSeconds: metadata.timeline.timelineEndSeconds,
|
endSeconds: metadata.timeline.timelineEndSeconds,
|
||||||
}) : null, [metadata.timeline]);
|
}) : null, [metadata.timeline]);
|
||||||
const playbackController = useRecordedEvidencePlayback(playbackRange, {
|
const playbackController = useRecordedEvidencePlayback(playbackRange, {
|
||||||
clock: mediaMode === "video" ? "external" : "animation",
|
clock: "external",
|
||||||
});
|
});
|
||||||
const seekPlayback = playbackController.seek;
|
const seekPlayback = playbackController.seek;
|
||||||
const setPlaybackPlaying = playbackController.setPlaying;
|
const setPlaybackPlaying = playbackController.setPlaying;
|
||||||
@@ -249,7 +293,9 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId,
|
resultId,
|
||||||
timeline: metadata.timeline,
|
timeline: metadata.timeline,
|
||||||
currentSeconds: playbackController.playback.currentSeconds,
|
currentSeconds: playbackController.playback.currentSeconds,
|
||||||
|
includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
|
||||||
endpointRoot: timelineEndpointRoot,
|
endpointRoot: timelineEndpointRoot,
|
||||||
|
spatialPlaybackTransport,
|
||||||
});
|
});
|
||||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||||
const [videoLoading, setVideoLoading] = useState(false);
|
const [videoLoading, setVideoLoading] = useState(false);
|
||||||
@@ -260,16 +306,12 @@ export function M4ReplayThreatVisual({
|
|||||||
setVideoError(null);
|
setVideoError(null);
|
||||||
}, [resultId]);
|
}, [resultId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const query = window.matchMedia("(max-width: 900px)");
|
|
||||||
const update = () => setSplitOrientation(query.matches ? "horizontal" : "vertical");
|
|
||||||
update();
|
|
||||||
query.addEventListener("change", update);
|
|
||||||
return () => query.removeEventListener("change", update);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeline = metadata.timeline;
|
const timeline = metadata.timeline;
|
||||||
|
if (!evidenceDemand.recordedVideo) {
|
||||||
|
setVideoLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!timeline || videoSource) return;
|
if (!timeline || videoSource) return;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setVideoLoading(true);
|
setVideoLoading(true);
|
||||||
@@ -304,7 +346,7 @@ export function M4ReplayThreatVisual({
|
|||||||
if (!controller.signal.aborted) setVideoLoading(false);
|
if (!controller.signal.aborted) setVideoLoading(false);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [metadata.timeline, videoSource]);
|
}, [evidenceDemand.recordedVideo, metadata.timeline, videoSource]);
|
||||||
|
|
||||||
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
const lastFrameRef = useRef<M4ThreatTimelineFrame | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -319,41 +361,53 @@ export function M4ReplayThreatVisual({
|
|||||||
resultId: string;
|
resultId: string;
|
||||||
frame: M4ThreatTimelineFrame;
|
frame: M4ThreatTimelineFrame;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
if (frame?.spatialAvailable) {
|
useEffect(() => {
|
||||||
lastSpatialFrameRef.current = { resultId, frame };
|
lastSpatialFrameRef.current = null;
|
||||||
|
}, [evidenceDemand.sourceSpatialPoints, resultId]);
|
||||||
|
const latestAvailableSpatialFrame = [...timelineFrame.availableFrames]
|
||||||
|
.reverse()
|
||||||
|
.find((candidate) => (
|
||||||
|
candidate.spatialAvailable
|
||||||
|
&& (timelineFrame.activeSequence === null
|
||||||
|
|| candidate.sequence <= timelineFrame.activeSequence)
|
||||||
|
)) ?? null;
|
||||||
|
const currentSpatialFrame = frame?.spatialAvailable ? frame : latestAvailableSpatialFrame;
|
||||||
|
if (currentSpatialFrame) {
|
||||||
|
lastSpatialFrameRef.current = { resultId, frame: currentSpatialFrame };
|
||||||
}
|
}
|
||||||
const spatialFrame = frame?.spatialAvailable
|
const spatialFrame = currentSpatialFrame
|
||||||
? frame
|
? currentSpatialFrame
|
||||||
: lastSpatialFrameRef.current?.resultId === resultId
|
: lastSpatialFrameRef.current?.resultId === resultId
|
||||||
? lastSpatialFrameRef.current.frame
|
? lastSpatialFrameRef.current.frame
|
||||||
: null;
|
: null;
|
||||||
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
const cameraPointOverlay = useM4ThreatCameraPointOverlay({
|
||||||
enabled: showReferenceMediaLayers && showMediaPoints,
|
enabled: showReferenceMediaLayers && evidenceDemand.cameraPointOverlay,
|
||||||
resultId,
|
resultId,
|
||||||
sequence: frame?.sequence ?? null,
|
sequence: frame?.sequence ?? null,
|
||||||
endpointRoot: timelineEndpointRoot,
|
endpointRoot: timelineEndpointRoot,
|
||||||
});
|
});
|
||||||
const semanticSpatialResultId = activeSemantic
|
const semanticSpatialResultId = activeSpatialSemantic
|
||||||
? activeSemantic.spatialResultId === undefined
|
? activeSpatialSemantic.spatialResultId === undefined
|
||||||
? activeSemantic.resultId
|
? activeSpatialSemantic.resultId
|
||||||
: activeSemantic.spatialResultId
|
: activeSpatialSemantic.spatialResultId
|
||||||
: null;
|
: null;
|
||||||
const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>(
|
const spatialSemanticTaxonomy = useMemo<readonly E47SemanticClass[]>(
|
||||||
() => semanticSpatialResultId && activeSemantic
|
() => semanticSpatialResultId && activeSpatialSemantic
|
||||||
? activeSemantic.taxonomy.map((item) => ({
|
? activeSpatialSemantic.taxonomy.map((item) => ({
|
||||||
classId: item.classId,
|
classId: item.classId,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled",
|
disposition: item.disposition === "ambiguous" ? "ambiguous" : "labeled",
|
||||||
colorRgb: item.colorRgb,
|
colorRgb: item.colorRgb,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
[activeSemantic, semanticSpatialResultId],
|
[activeSpatialSemantic, semanticSpatialResultId],
|
||||||
);
|
);
|
||||||
const semanticTimeline = useE47SemanticTimelineFrame({
|
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||||
resultId: semanticSpatialResultId,
|
resultId: semanticSpatialResultId,
|
||||||
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||||
frameCount: metadata.timeline?.frameCount ?? 0,
|
frameCount: metadata.timeline?.frameCount ?? 0,
|
||||||
taxonomy: spatialSemanticTaxonomy,
|
taxonomy: spatialSemanticTaxonomy,
|
||||||
|
enabled: evidenceDemand.selectedSemanticPoints,
|
||||||
});
|
});
|
||||||
const displayingBufferedFrame = Boolean(
|
const displayingBufferedFrame = Boolean(
|
||||||
frame
|
frame
|
||||||
@@ -443,6 +497,27 @@ export function M4ReplayThreatVisual({
|
|||||||
})) ?? [],
|
})) ?? [],
|
||||||
[activeSemantic?.taxonomy],
|
[activeSemantic?.taxonomy],
|
||||||
);
|
);
|
||||||
|
const spatialSemanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||||
|
() => activeSpatialSemantic?.taxonomy.map((item) => ({
|
||||||
|
id: item.classId,
|
||||||
|
label: `semantic: ${item.label}`,
|
||||||
|
})) ?? [],
|
||||||
|
[activeSpatialSemantic?.taxonomy],
|
||||||
|
);
|
||||||
|
const spatialSemanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
|
||||||
|
() => activeSpatialSemantic?.taxonomy.map((item) => ({
|
||||||
|
classId: item.classId,
|
||||||
|
color: item.disposition === "undefined"
|
||||||
|
? { kind: "transparent" as const }
|
||||||
|
: item.disposition === "ambiguous"
|
||||||
|
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||||
|
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||||
|
opacity: item.disposition === "undefined"
|
||||||
|
? 0
|
||||||
|
: item.disposition === "ambiguous" ? 0.52 : 0.92,
|
||||||
|
})) ?? [],
|
||||||
|
[activeSpatialSemantic?.taxonomy],
|
||||||
|
);
|
||||||
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||||
? semanticTimeline.activeFrame
|
? semanticTimeline.activeFrame
|
||||||
: null;
|
: null;
|
||||||
@@ -459,7 +534,7 @@ export function M4ReplayThreatVisual({
|
|||||||
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
|
&& lastSpatialSemanticFrameRef.current.frame.sequence === spatialFrame?.sequence
|
||||||
? lastSpatialSemanticFrameRef.current.frame
|
? lastSpatialSemanticFrameRef.current.frame
|
||||||
: null;
|
: null;
|
||||||
const semanticIntegrityError = activeSemantic && spatialFrame && spatialSemanticFrame && (
|
const semanticIntegrityError = activeSpatialSemantic && spatialFrame && spatialSemanticFrame && (
|
||||||
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|
spatialSemanticFrame.sourcePointCount !== spatialFrame.pointCloudSourceCount
|
||||||
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|
|| spatialFrame.pointCloudSampleCount !== spatialFrame.pointCloudSourceCount
|
||||||
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
|
|| spatialFrame.pointCloudBodyXyzM.length !== spatialFrame.pointCloudSourceCount
|
||||||
@@ -468,7 +543,7 @@ export function M4ReplayThreatVisual({
|
|||||||
: null;
|
: null;
|
||||||
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||||
if (
|
if (
|
||||||
!activeSemantic
|
!activeSpatialSemantic
|
||||||
|| !showSpatialSemantic
|
|| !showSpatialSemantic
|
||||||
|| !spatialFrame
|
|| !spatialFrame
|
||||||
|| !spatialSemanticFrame
|
|| !spatialSemanticFrame
|
||||||
@@ -478,18 +553,24 @@ export function M4ReplayThreatVisual({
|
|||||||
const status = spatialSemanticFrame.statusCodes[index];
|
const status = spatialSemanticFrame.statusCodes[index];
|
||||||
return status === 2 || status === 3 ? classId : null;
|
return status === 2 || status === 3 ? classId : null;
|
||||||
});
|
});
|
||||||
}, [activeSemantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
}, [activeSpatialSemantic, semanticIntegrityError, showSpatialSemantic, spatialFrame, spatialSemanticFrame]);
|
||||||
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
|
const activeSpatialFrame = spatialFrame?.sequence === timelineFrame.activeSequence
|
||||||
? spatialFrame
|
? spatialFrame
|
||||||
: null;
|
: null;
|
||||||
const classifiedSpatialFrame = classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
const hasClassifiedSpatialOutput = Boolean(
|
||||||
|
classifiedSpatialLayer && classifiedSpatialLayer.cellLayerAvailable !== false,
|
||||||
|
);
|
||||||
|
const classifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||||
|
&& classifiedSpatialLayer?.frame?.sourceSequence === timelineFrame.activeSequence
|
||||||
? classifiedSpatialLayer?.frame ?? null
|
? classifiedSpatialLayer?.frame ?? null
|
||||||
: null;
|
: null;
|
||||||
const lastClassifiedSpatialFrameRef = useRef<{
|
const lastClassifiedSpatialFrameRef = useRef<{
|
||||||
resultId: string;
|
resultId: string;
|
||||||
frame: M4ReplayClassifiedSpatialFrame;
|
frame: M4ReplayClassifiedSpatialFrame;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null;
|
const incomingClassifiedSpatialFrame = hasClassifiedSpatialOutput
|
||||||
|
? classifiedSpatialLayer?.frame ?? null
|
||||||
|
: null;
|
||||||
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) {
|
||||||
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame };
|
||||||
}
|
}
|
||||||
@@ -518,7 +599,9 @@ export function M4ReplayThreatVisual({
|
|||||||
? spatialFrame
|
? spatialFrame
|
||||||
: null)
|
: null)
|
||||||
: null;
|
: null;
|
||||||
const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true;
|
const replaceClassifiedPointCloud = hasClassifiedSpatialOutput
|
||||||
|
? classifiedSpatialLayer?.replacePointCloud ?? true
|
||||||
|
: false;
|
||||||
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0;
|
||||||
const mapGravityLocalSensorToBodyGround = useCallback((
|
const mapGravityLocalSensorToBodyGround = useCallback((
|
||||||
point: readonly [number, number, number],
|
point: readonly [number, number, number],
|
||||||
@@ -636,7 +719,12 @@ export function M4ReplayThreatVisual({
|
|||||||
.map((item) => item.assessment.closestApproachM)
|
.map((item) => item.assessment.closestApproachM)
|
||||||
.filter((value): value is number => value !== null)
|
.filter((value): value is number => value !== null)
|
||||||
.sort((left, right) => left - right)[0] ?? null;
|
.sort((left, right) => left - right)[0] ?? null;
|
||||||
const localSurface = useMemo(() => buildM4LocalSurface(
|
const localSurface = useMemo(() => spatialFrame?.localSlamBodyXyzM?.length ? ({
|
||||||
|
pointsBodyXyzM: spatialFrame.localSlamBodyXyzM,
|
||||||
|
sourceFrameCount: spatialFrame.localSlamSourceFrameCount ?? 0,
|
||||||
|
sourcePointCount: spatialFrame.localSlamSourcePointCount ?? 0,
|
||||||
|
voxelCount: spatialFrame.localSlamBodyXyzM.length,
|
||||||
|
}) : buildM4LocalSurface(
|
||||||
timelineFrame.availableFrames,
|
timelineFrame.availableFrames,
|
||||||
spatialFrame,
|
spatialFrame,
|
||||||
metadata.timeline?.localSurfaceVisualization ?? {
|
metadata.timeline?.localSurfaceVisualization ?? {
|
||||||
@@ -647,7 +735,7 @@ export function M4ReplayThreatVisual({
|
|||||||
},
|
},
|
||||||
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
), [metadata.timeline, spatialFrame, timelineFrame.availableFrames]);
|
||||||
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||||
activeSemantic && showMediaSemantic && frame
|
activeSemantic && evidenceDemand.selectedSemanticMask && frame
|
||||||
? {
|
? {
|
||||||
src: activeSemantic.maskUrl?.(frame.sequence)
|
src: activeSemantic.maskUrl?.(frame.sequence)
|
||||||
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
|
?? e47SemanticMaskUrl(activeSemantic.resultId, frame.sequence),
|
||||||
@@ -684,50 +772,15 @@ export function M4ReplayThreatVisual({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleMediaModeChange = (next: M4ThreatMediaSelection) => {
|
|
||||||
if (next === "none") return;
|
|
||||||
setMediaMode((current) => current === next ? null : next);
|
|
||||||
};
|
|
||||||
const handleSpatialModeChange = (next: M4ThreatSpatialSelection) => {
|
|
||||||
if (next === "none") return;
|
|
||||||
setSpatialMode((current) => current === next ? null : next);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playbackController.playback.playing || !frame) return;
|
if (
|
||||||
|
playbackController.playback.playing
|
||||||
|
|| !evidenceDemand.exactCameraFrame
|
||||||
|
|| !frame
|
||||||
|
) return;
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = frame.cameraUrl;
|
image.src = frame.cameraUrl;
|
||||||
}, [frame?.cameraUrl, playbackController.playback.playing]);
|
}, [evidenceDemand.exactCameraFrame, frame?.cameraUrl, playbackController.playback.playing]);
|
||||||
|
|
||||||
const splitView = mediaMode !== null && spatialMode !== null;
|
|
||||||
|
|
||||||
const mediaModeControls = (
|
|
||||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="media">
|
|
||||||
<SegmentedControl
|
|
||||||
value={mediaMode ?? "none"}
|
|
||||||
items={[
|
|
||||||
{ value: "video", label: "VIDEO" },
|
|
||||||
{ value: "camera", label: "CAMERA" },
|
|
||||||
]}
|
|
||||||
label="Видео и камера"
|
|
||||||
onChange={handleMediaModeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const spatialModeControls = (
|
|
||||||
<div className="m4-replay-threat-visual__pane-mode-controls" data-pane-mode="spatial">
|
|
||||||
<SegmentedControl
|
|
||||||
value={spatialMode ?? "none"}
|
|
||||||
items={[
|
|
||||||
{ value: "3d", label: "3D" },
|
|
||||||
{ value: "plan", label: "PLAN" },
|
|
||||||
]}
|
|
||||||
label="3D и план"
|
|
||||||
onChange={handleSpatialModeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mediaLayerControls = activeSemantic
|
const mediaLayerControls = activeSemantic
|
||||||
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
|
|| (showReferenceMediaLayers && metadata.timeline?.cameraPointDelivery)
|
||||||
@@ -819,16 +872,24 @@ export function M4ReplayThreatVisual({
|
|||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showRollingMap ? "primary" : "secondary"}
|
variant={showRollingMap ? "primary" : "secondary"}
|
||||||
aria-pressed={showRollingMap}
|
aria-pressed={showRollingMap}
|
||||||
|
disabled={classifiedSpatialLayer.cellLayerAvailable === false}
|
||||||
|
title={classifiedSpatialLayer.cellLayerAvailable === false
|
||||||
|
? `${classifiedSpatialLayer.cellLayerLabel} недоступен: для этой записи нет запечатанного полного результата`
|
||||||
|
: undefined}
|
||||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||||
>
|
>
|
||||||
{classifiedSpatialLayer.cellLayerLabel}
|
{classifiedSpatialLayer.cellLayerLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{semanticSpatialResultId ? (
|
{activeSpatialSemantic ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||||
aria-pressed={showSpatialSemantic}
|
aria-pressed={showSpatialSemantic}
|
||||||
|
disabled={!semanticSpatialResultId}
|
||||||
|
title={semanticSpatialResultId
|
||||||
|
? "Point-aligned semantic evidence"
|
||||||
|
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||||
>
|
>
|
||||||
SEMANTICS
|
SEMANTICS
|
||||||
@@ -881,12 +942,16 @@ export function M4ReplayThreatVisual({
|
|||||||
LOW-STEP
|
LOW-STEP
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{semanticSpatialResultId ? (
|
{activeSpatialSemantic ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
variant={showSpatialSemantic ? "primary" : "secondary"}
|
variant={showSpatialSemantic ? "primary" : "secondary"}
|
||||||
aria-pressed={showSpatialSemantic}
|
aria-pressed={showSpatialSemantic}
|
||||||
|
disabled={!semanticSpatialResultId}
|
||||||
|
title={semanticSpatialResultId
|
||||||
|
? "Point-aligned semantic evidence"
|
||||||
|
: "Point-aligned 3D semantics отсутствует в запечатанном результате"}
|
||||||
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
onClick={() => setShowSpatialSemantic((visible) => !visible)}
|
||||||
>
|
>
|
||||||
SEMANTICS
|
SEMANTICS
|
||||||
@@ -990,14 +1055,14 @@ export function M4ReplayThreatVisual({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<span>Spatial evidence</span>
|
<span>Spatial evidence</span>
|
||||||
<strong>{classifiedSpatialLayer
|
<strong>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? replaceClassifiedPointCloud
|
? replaceClassifiedPointCloud
|
||||||
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells`
|
||||||
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
: `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells`
|
||||||
: "TGS spatial buffer"
|
: "TGS spatial buffer"
|
||||||
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
: `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`}</strong>
|
||||||
<small>{classifiedSpatialLayer
|
<small>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? classifiedSpatialFrame.sampleAvailable === false
|
? classifiedSpatialFrame.sampleAvailable === false
|
||||||
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame
|
||||||
@@ -1006,9 +1071,9 @@ export function M4ReplayThreatVisual({
|
|||||||
: activeSpatialFrame
|
: activeSpatialFrame
|
||||||
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
? "map-gravity-local · all eligible points accounted · causal rolling 1 s"
|
||||||
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
: "TGS рассчитан · linked source cloud недоступен для этого кадра"
|
||||||
: classifiedSpatialLayer.error
|
: classifiedSpatialLayer?.error
|
||||||
?? classifiedSpatialLayer.loadingLabel
|
?? classifiedSpatialLayer?.loadingLabel
|
||||||
?? `Открываем ${classifiedSpatialLayer.label}`
|
?? `Открываем ${classifiedSpatialLayer?.label ?? "spatial evidence"}`
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
{spatialFrame
|
{spatialFrame
|
||||||
@@ -1033,13 +1098,13 @@ export function M4ReplayThreatVisual({
|
|||||||
)}</small>
|
)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{classifiedSpatialLayer ? "TGS fail-closed" : "Virtual corridor"}</span>
|
<span>{hasClassifiedSpatialOutput ? "TGS fail-closed" : "Virtual corridor"}</span>
|
||||||
<strong>{classifiedSpatialLayer
|
<strong>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
? `${classifiedCellCounts.occupied} occupied · ${classifiedCellCounts.rejected} rejected · ${classifiedCellCounts.unobserved} unobserved`
|
||||||
: classifiedSpatialLayer.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
: classifiedSpatialLayer?.loading || displayingBufferedFrame ? "loading" : "unavailable"
|
||||||
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
|
: `${spatialFrame?.decisionCounts.threat ?? 0} threat · nearest ${nearest === null ? "—" : `${nearest.toFixed(2)} м`}`}</strong>
|
||||||
<small>{classifiedSpatialLayer
|
<small>{hasClassifiedSpatialOutput
|
||||||
? classifiedSpatialFrame
|
? classifiedSpatialFrame
|
||||||
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
? `${classifiedCellCounts.ground} ground-support · visual review only · navigation authority OFF`
|
||||||
: "visual review only · navigation authority OFF"
|
: "visual review only · navigation authority OFF"
|
||||||
@@ -1051,7 +1116,12 @@ export function M4ReplayThreatVisual({
|
|||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const timeline = metadata.timeline;
|
const timeline = metadata.timeline;
|
||||||
let content;
|
let content: ReactNode = null;
|
||||||
|
let canonicalContent: {
|
||||||
|
mediaContent: ReactNode;
|
||||||
|
spatialContent: ReactNode;
|
||||||
|
deckOverlays: ReactNode;
|
||||||
|
} | null = null;
|
||||||
if (metadata.error) {
|
if (metadata.error) {
|
||||||
content = <SpatialState message={metadata.error} />;
|
content = <SpatialState message={metadata.error} />;
|
||||||
} else if (!timeline) {
|
} else if (!timeline) {
|
||||||
@@ -1062,23 +1132,8 @@ export function M4ReplayThreatVisual({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const mediaPane = (
|
const mediaContent = (
|
||||||
<section
|
<>
|
||||||
className="m4-replay-threat-visual__pane"
|
|
||||||
data-pane="media"
|
|
||||||
aria-label={mediaMode === "camera" ? "Камера" : "Видео"}
|
|
||||||
hidden={!mediaMode}
|
|
||||||
>
|
|
||||||
{splitView ? (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-toolbar"
|
|
||||||
data-pane-toolbar="media"
|
|
||||||
data-multi-semantic={availableSemanticLayers.length > 1 ? "true" : undefined}
|
|
||||||
>
|
|
||||||
{mediaLayerControls}
|
|
||||||
{mediaModeControls}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
<div
|
||||||
className="m4-replay-threat-visual__media-layer"
|
className="m4-replay-threat-visual__media-layer"
|
||||||
data-media="video"
|
data-media="video"
|
||||||
@@ -1091,6 +1146,7 @@ export function M4ReplayThreatVisual({
|
|||||||
imageWidth={timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
|
overlaySeconds={frame?.sessionSeconds}
|
||||||
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
|
||||||
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
|
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
|
||||||
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||||
@@ -1102,7 +1158,9 @@ export function M4ReplayThreatVisual({
|
|||||||
}
|
}
|
||||||
segmentCount={timeline.frameCount}
|
segmentCount={timeline.frameCount}
|
||||||
onPlaybackChange={playbackController.synchronize}
|
onPlaybackChange={playbackController.synchronize}
|
||||||
onPlayingRejected={() => playbackController.setPlaying(false)}
|
playbackAuthority="media"
|
||||||
|
playbackTransport={playbackTransport}
|
||||||
|
recoverTimestampStalls={recoverTimestampStalls}
|
||||||
/>
|
/>
|
||||||
) : videoError ? (
|
) : videoError ? (
|
||||||
<SpatialState message={videoError} />
|
<SpatialState message={videoError} />
|
||||||
@@ -1124,34 +1182,20 @@ export function M4ReplayThreatVisual({
|
|||||||
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const spatialPane = spatialMode ? (
|
const spatialContent = spatialMode ? (
|
||||||
<section
|
<>
|
||||||
className="m4-replay-threat-visual__pane"
|
|
||||||
data-pane="spatial"
|
|
||||||
aria-label={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
|
||||||
>
|
|
||||||
{splitView ? (
|
|
||||||
<div
|
|
||||||
className="m4-replay-threat-visual__pane-toolbar"
|
|
||||||
data-pane-toolbar="spatial"
|
|
||||||
>
|
|
||||||
{resetSpatialView}
|
|
||||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
|
||||||
{spatialLayerControls}
|
|
||||||
{spatialModeControls}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<LaboratoryMetricEvidenceScene
|
<LaboratoryMetricEvidenceScene
|
||||||
ref={metricSceneRef}
|
ref={metricSceneRef}
|
||||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? classifiedPointsBody
|
? classifiedPointsBody
|
||||||
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []}
|
: classifiedContextSpatialFrame?.pointCloudBodyXyzM
|
||||||
|
?? activeSpatialFrame?.pointCloudBodyXyzM
|
||||||
|
?? []}
|
||||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||||
obstacles={classifiedSpatialLayer ? [] : sceneObstacles}
|
obstacles={hasClassifiedSpatialOutput ? [] : sceneObstacles}
|
||||||
rig={timeline.rig}
|
rig={timeline.rig}
|
||||||
corridor={timeline.corridor}
|
corridor={timeline.corridor}
|
||||||
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
||||||
@@ -1160,22 +1204,22 @@ export function M4ReplayThreatVisual({
|
|||||||
showCurrentIncrement={showCurrentIncrement}
|
showCurrentIncrement={showCurrentIncrement}
|
||||||
showLocalSurface={showLocalSurface}
|
showLocalSurface={showLocalSurface}
|
||||||
showRollingMap={showRollingMap}
|
showRollingMap={showRollingMap}
|
||||||
showLowStep={classifiedSpatialLayer ? false : showLowStep}
|
showLowStep={hasClassifiedSpatialOutput ? false : showLowStep}
|
||||||
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? displayedClassifiedSpatialFrame.pointClassIds
|
? displayedClassifiedSpatialFrame.pointClassIds
|
||||||
: alignedSemanticPointIds}
|
: alignedSemanticPointIds}
|
||||||
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? displayedClassifiedSpatialFrame.classes
|
? displayedClassifiedSpatialFrame.classes
|
||||||
: semanticClasses}
|
: spatialSemanticClasses}
|
||||||
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||||
? displayedClassifiedSpatialFrame.palette
|
? displayedClassifiedSpatialFrame.palette
|
||||||
: semanticPalette}
|
: spatialSemanticPalette}
|
||||||
classifiedCells={classifiedCellsBody}
|
classifiedCells={classifiedCellsBody}
|
||||||
classifiedPackedCells={classifiedPackedCellsBody}
|
classifiedPackedCells={classifiedPackedCellsBody}
|
||||||
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
||||||
showClassifiedCells={showRollingMap}
|
showClassifiedCells={showRollingMap}
|
||||||
/>
|
/>
|
||||||
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
{hasClassifiedSpatialOutput && classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
||||||
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
||||||
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||||
? <span className="busy-indicator" aria-hidden="true" />
|
? <span className="busy-indicator" aria-hidden="true" />
|
||||||
@@ -1206,31 +1250,11 @@ export function M4ReplayThreatVisual({
|
|||||||
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
: `На кадре ${frame.sequence + 1} нет body frame; ждём первый квалифицированный spatial evidence.`}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
content = (
|
const deckOverlays = (
|
||||||
<div
|
<>
|
||||||
className="m4-replay-threat-visual__deck"
|
|
||||||
data-split={splitView ? "true" : undefined}
|
|
||||||
data-empty={!mediaMode && !spatialMode ? "true" : undefined}
|
|
||||||
>
|
|
||||||
<SplitPane
|
|
||||||
primary={mediaPane}
|
|
||||||
secondary={spatialPane ?? <div />}
|
|
||||||
primarySize={splitView ? splitPrimarySize : mediaMode ? 100 : 0}
|
|
||||||
onPrimarySizeChange={setSplitPrimarySize}
|
|
||||||
orientation={splitOrientation}
|
|
||||||
minPrimarySize={splitView ? 24 : 0}
|
|
||||||
minSecondarySize={splitView ? 24 : 0}
|
|
||||||
resizable={splitView}
|
|
||||||
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
|
|
||||||
/>
|
|
||||||
{!mediaMode && !spatialMode ? (
|
|
||||||
<div className="l3-visual-audit__state" role="status">
|
|
||||||
Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{timelineFrame.loading || displayingBufferedFrame ? (
|
{timelineFrame.loading || displayingBufferedFrame ? (
|
||||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
<span className="busy-indicator" aria-hidden="true" />
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
@@ -1261,8 +1285,9 @@ export function M4ReplayThreatVisual({
|
|||||||
<span>{semanticIntegrityError}</span>
|
<span>{semanticIntegrityError}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
canonicalContent = { mediaContent, spatialContent, deckOverlays };
|
||||||
}
|
}
|
||||||
|
|
||||||
const transport = timeline ? (
|
const transport = timeline ? (
|
||||||
@@ -1287,38 +1312,59 @@ export function M4ReplayThreatVisual({
|
|||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
if (!timeline || !canonicalContent) {
|
||||||
return (
|
return (
|
||||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||||
<LaboratoryEvidenceViewer
|
<LaboratoryEvidenceViewer
|
||||||
label={activeSemantic
|
label={`${evidenceLabel} recorded-realtime replay`}
|
||||||
? activeSemantic.label ?? "Semantic diagnostic replay"
|
|
||||||
: `${evidenceLabel} recorded-realtime replay`}
|
|
||||||
className="m4-replay-threat-evidence-viewer"
|
className="m4-replay-threat-evidence-viewer"
|
||||||
mode={mediaMode ?? "none"}
|
mode="video"
|
||||||
modes={[
|
modes={[{ value: "video", label: "VIDEO" }]}
|
||||||
{ value: "video", label: "VIDEO" },
|
|
||||||
{ value: "camera", label: "CAMERA" },
|
|
||||||
]}
|
|
||||||
secondaryMode={{
|
|
||||||
value: spatialMode ?? "none",
|
|
||||||
modes: [
|
|
||||||
{ value: "3d", label: "3D" },
|
|
||||||
{ value: "plan", label: "PLAN" },
|
|
||||||
],
|
|
||||||
label: "3D и план",
|
|
||||||
onChange: handleSpatialModeChange,
|
|
||||||
}}
|
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onModeChange={handleMediaModeChange}
|
onModeChange={() => undefined}
|
||||||
onExpandedChange={setExpanded}
|
onExpandedChange={setExpanded}
|
||||||
modeControlsVisible={!splitView}
|
|
||||||
actions={actions}
|
|
||||||
overlay={overlay}
|
|
||||||
transport={transport}
|
|
||||||
trailingActions={trailingActions}
|
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</LaboratoryEvidenceViewer>
|
</LaboratoryEvidenceViewer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CanonicalRecordedLabReplay
|
||||||
|
label={activeSemantic
|
||||||
|
? activeSemantic.label ?? "Semantic diagnostic replay"
|
||||||
|
: `${evidenceLabel} recorded-realtime replay`}
|
||||||
|
mediaMode={mediaMode ?? "none"}
|
||||||
|
mediaModes={[
|
||||||
|
{ value: "video", label: "VIDEO" },
|
||||||
|
{ value: "camera", label: "CAMERA" },
|
||||||
|
]}
|
||||||
|
spatialMode={spatialMode ?? "none"}
|
||||||
|
spatialModes={[
|
||||||
|
{ value: "3d", label: "3D" },
|
||||||
|
{ value: "plan", label: "PLAN" },
|
||||||
|
]}
|
||||||
|
expanded={expanded}
|
||||||
|
splitPrimarySize={splitPrimarySize}
|
||||||
|
splitOrientation={splitOrientation}
|
||||||
|
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео"}
|
||||||
|
spatialAriaLabel={spatialMode === "3d" ? "Трёхмерная сцена" : "Вид сверху"}
|
||||||
|
mediaLayerControls={mediaLayerControls}
|
||||||
|
spatialLayerControls={spatialLayerControls}
|
||||||
|
spatialLeadingControl={resetSpatialView}
|
||||||
|
mediaMultiLayer={availableSemanticLayers.length > 1}
|
||||||
|
mediaContent={canonicalContent.mediaContent}
|
||||||
|
spatialContent={canonicalContent.spatialContent}
|
||||||
|
emptyMessage="Выберите VIDEO/CAMERA или 3D/PLAN. Общий таймлайн останется на месте."
|
||||||
|
deckOverlays={canonicalContent.deckOverlays}
|
||||||
|
actions={actions}
|
||||||
|
overlay={overlay}
|
||||||
|
transport={transport}
|
||||||
|
trailingActions={trailingActions}
|
||||||
|
onMediaModeChange={handleMediaModeChange}
|
||||||
|
onSpatialModeChange={handleSpatialModeChange}
|
||||||
|
onExpandedChange={setExpanded}
|
||||||
|
onSplitPrimarySizeChange={setSplitPrimarySize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LaboratoryEvidence,
|
LaboratoryEvidence,
|
||||||
@@ -7,22 +7,166 @@ import {
|
|||||||
LaboratoryWorkTemplate,
|
LaboratoryWorkTemplate,
|
||||||
} from "../../components/laboratory/LaboratoryPresentation";
|
} from "../../components/laboratory/LaboratoryPresentation";
|
||||||
import {
|
import {
|
||||||
|
vegetationFullRouteMaskUrl,
|
||||||
vegetationVideoMaskUrl,
|
vegetationVideoMaskUrl,
|
||||||
|
type VegetationFullRouteReview,
|
||||||
type VegetationShadowResult,
|
type VegetationShadowResult,
|
||||||
} from "../../core/laboratory/vegetationShadow";
|
} from "../../core/laboratory/vegetationShadow";
|
||||||
import {
|
import {
|
||||||
fetchM49TgsFullShadowResult,
|
fetchM49TgsFullShadowResult,
|
||||||
type M49TgsFullShadowResult,
|
type M49TgsFullShadowResult,
|
||||||
} from "../../core/laboratory/m49TgsFullShadow";
|
} from "../../core/laboratory/m49TgsFullShadow";
|
||||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
|
||||||
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
||||||
|
import {
|
||||||
|
M4ReplayThreatVisual,
|
||||||
|
type M4ReplayClassifiedSpatialLayer,
|
||||||
|
type M4ReplayThreatSemanticLayer,
|
||||||
|
} from "./M4ReplayThreatVisual";
|
||||||
|
|
||||||
|
const VEGETATION_TIMELINE_ENDPOINT = "/api/v1/laboratory/vegetation-shadow";
|
||||||
|
|
||||||
function decimal(value: number, digits = 1): string {
|
function decimal(value: number, digits = 1): string {
|
||||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FullRouteReviewEvidence({
|
||||||
|
resultId,
|
||||||
|
review,
|
||||||
|
}: {
|
||||||
|
resultId: string;
|
||||||
|
review: VegetationFullRouteReview;
|
||||||
|
}) {
|
||||||
|
const semanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(() => ([
|
||||||
|
{
|
||||||
|
id: "city",
|
||||||
|
controlLabel: "ГОРОД · EoMT",
|
||||||
|
resultId,
|
||||||
|
spatialResultId: null,
|
||||||
|
taxonomy: review.city.taxonomy,
|
||||||
|
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "city", sequence),
|
||||||
|
label: review.city.name,
|
||||||
|
maskAriaLabel: "EoMT city semantic prediction",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vegetation",
|
||||||
|
controlLabel: "ПРИРОДА · DDRNet",
|
||||||
|
resultId,
|
||||||
|
spatialResultId: null,
|
||||||
|
taxonomy: review.vegetation.taxonomy,
|
||||||
|
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "vegetation", sequence),
|
||||||
|
label: review.vegetation.name,
|
||||||
|
maskAriaLabel: "DDRNet nature semantic prediction",
|
||||||
|
},
|
||||||
|
]), [resultId, review.city, review.vegetation]);
|
||||||
|
const sealedSpatialGap = useMemo<M4ReplayClassifiedSpatialLayer>(() => ({
|
||||||
|
label: "RAVNOVES004TREE",
|
||||||
|
pointLayerLabel: "SOURCE POINTS",
|
||||||
|
cellLayerLabel: "TGS COSTMAP",
|
||||||
|
cellLayerAvailable: false,
|
||||||
|
expectedAtSequence: false,
|
||||||
|
frame: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
replacePointCloud: false,
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<M4ReplayThreatVisual
|
||||||
|
resultId={resultId}
|
||||||
|
timelineEndpointRoot={VEGETATION_TIMELINE_ENDPOINT}
|
||||||
|
semanticLayers={semanticLayers}
|
||||||
|
initialSemanticLayerId="vegetation"
|
||||||
|
initialSpatialMode="3d"
|
||||||
|
classifiedSpatialLayer={sealedSpatialGap}
|
||||||
|
evidenceLabel="RAVNOVES004TREE"
|
||||||
|
playbackTransport="segmented"
|
||||||
|
spatialPlaybackTransport="sealed-binary"
|
||||||
|
recoverTimestampStalls
|
||||||
|
showReferenceMediaLayers
|
||||||
|
showSpatialOverlaySummary
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullRouteReviewResult({
|
||||||
|
rigLabel,
|
||||||
|
resultId,
|
||||||
|
review,
|
||||||
|
}: {
|
||||||
|
rigLabel: string;
|
||||||
|
resultId: string;
|
||||||
|
review: VegetationFullRouteReview;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<LaboratoryWorkTemplate
|
||||||
|
summary={(
|
||||||
|
<LaboratorySummary
|
||||||
|
title="LAB V1 · RAVNOVES004TREE · полный маршрут"
|
||||||
|
description="Принятый recorded-LAB инструмент воспроизводит RAV004 без отдельного viewer: одна media-clock timeline, RIGHT camera, source points, bounded Local SLAM и переключаемые EoMT/DDRNet."
|
||||||
|
status="FULL RECORDED REVIEW · truth отсутствует · commands OFF"
|
||||||
|
statusTone="warning"
|
||||||
|
facts={[
|
||||||
|
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} camera frames` },
|
||||||
|
{ label: "3D", value: "1444 source cloud increments · gravity-stable RFU → body" },
|
||||||
|
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} fps` },
|
||||||
|
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} fps` },
|
||||||
|
{ label: "TGS", value: "10 review anchors существуют · full-route artifact отсутствует" },
|
||||||
|
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
|
||||||
|
]}
|
||||||
|
brief={{
|
||||||
|
question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
|
||||||
|
approach: "RAV004 поставляет только data/provider configuration в тот же M4 recorded viewer. Видеодекодер владеет clock; новые source increments проецируются в gravity-stable forward/left/up frame, Local SLAM ограничен пятью секундами.",
|
||||||
|
principalResult: "RAV004 больше не имеет отдельной логики окон, таймера, seek, cache или 3D controls. Модели и подписи меняются конфигурацией, архитектура переключения остаётся общей.",
|
||||||
|
limitation: "Full-route TGS, независимый person/vehicle detector, ручной truth и point-aligned 3D semantics пока не запечатаны. Semantic-derived рамки диагностические и не являются STOP-authority.",
|
||||||
|
}}
|
||||||
|
method={{
|
||||||
|
completeness: "complete",
|
||||||
|
executionClass: "ai-inference",
|
||||||
|
pipelineId: "canonical-recorded-lab-rav004tree/v3",
|
||||||
|
components: [
|
||||||
|
{ kind: "algorithm", name: "Canonical recorded replay", version: "media-clock / one viewer", role: "shared camera + spatial transport", identitySha256: null },
|
||||||
|
{ kind: "algorithm", name: "Recorded source points + bounded Local SLAM", version: "source-paced-ground-v3", role: "gravity-stable spatial evidence", identitySha256: null },
|
||||||
|
{ kind: "model", name: review.city.name, version: "sealed Worker 006 run", role: "urban semantic review", identitySha256: null },
|
||||||
|
{ kind: "model", name: review.vegetation.name, version: "GOOSE DDRNet-39", role: "vegetation semantic review", identitySha256: null },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
evidence={(
|
||||||
|
<LaboratoryEvidence
|
||||||
|
eyebrow="CANONICAL RECORDED LAB · RAVNOVES004TREE"
|
||||||
|
title="CAMERA + SOURCE POINTS + LOCAL SLAM + TGS COSTMAP + SEMANTICS · 6830/6830"
|
||||||
|
kind="recorded-replay"
|
||||||
|
resizable
|
||||||
|
>
|
||||||
|
<FullRouteReviewEvidence resultId={resultId} review={review} />
|
||||||
|
</LaboratoryEvidence>
|
||||||
|
)}
|
||||||
|
result={(
|
||||||
|
<LaboratoryResultSummary
|
||||||
|
title="RAV004 переведён на общий replay-каркас; safety evidence ещё не полно"
|
||||||
|
status="Recorded evidence · navigation/actuation OFF"
|
||||||
|
statusTone="warning"
|
||||||
|
metrics={[
|
||||||
|
{ label: "Camera timeline", value: "6830 frames · ≈9.51 Hz", hint: "media clock owns video, overlays and spatial" },
|
||||||
|
{ label: "Source geometry", value: "1444 increments · ≈2 Hz", hint: "last proven spatial frame is held between source arrivals" },
|
||||||
|
{ label: "EoMT throughput", value: `${decimal(review.city.inferenceFps, 2)} fps`, hint: "изолированный full pass; не realtime stack" },
|
||||||
|
{ label: "DDRNet throughput", value: `${decimal(review.vegetation.inferenceFps, 2)} fps`, hint: "изолированный full pass; temporal stability не принята" },
|
||||||
|
]}
|
||||||
|
conclusion={{
|
||||||
|
proved: "Camera, seek, spatial layers and semantic switching use one accepted reusable viewer and one media clock; RFU source geometry no longer inherits LiDAR roll/pitch.",
|
||||||
|
notProved: "Не доказаны continuous TGS, независимый detector/STOP, truth accuracy, temporal stability DDRNet и ≥10 FPS совместного live stack.",
|
||||||
|
decision: "Продолжать как visual audit. До запечатанного full-route TGS и detector/load gate navigation/actuation остаются OFF.",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
|
function VegetationRouteEvidence({ result }: { result: VegetationShadowResult }) {
|
||||||
const route = result.routeVideo!;
|
const route = result.routeVideo!;
|
||||||
|
const linkedTgsResultId = route.linkedTgsResultId;
|
||||||
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
const [tgs, setTgs] = useState<M49TgsFullShadowResult | null>(null);
|
||||||
const [tgsError, setTgsError] = useState<string | null>(null);
|
const [tgsError, setTgsError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -30,8 +174,8 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setTgs(null);
|
setTgs(null);
|
||||||
setTgsError(null);
|
setTgsError(null);
|
||||||
if (!route.linkedTgsResultId) return () => controller.abort();
|
if (!linkedTgsResultId) return () => controller.abort();
|
||||||
void fetchM49TgsFullShadowResult(route.linkedTgsResultId, {
|
void fetchM49TgsFullShadowResult(linkedTgsResultId, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}).then((next) => {
|
}).then((next) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
@@ -45,7 +189,11 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [route.baseM4ResultId, route.linkedTgsResultId]);
|
}, [linkedTgsResultId, route.baseM4ResultId]);
|
||||||
|
|
||||||
|
if (!linkedTgsResultId) {
|
||||||
|
throw new Error("Vegetation LAB result has no linked canonical M4.9 TGS evidence.");
|
||||||
|
}
|
||||||
|
|
||||||
const semantic = {
|
const semantic = {
|
||||||
id: "vegetation",
|
id: "vegetation",
|
||||||
@@ -58,16 +206,14 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
maskAriaLabel: "DDRNet vegetation material prediction",
|
maskAriaLabel: "DDRNet vegetation material prediction",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (route.linkedTgsResultId && tgs) {
|
if (tgsError) {
|
||||||
return (
|
return (
|
||||||
<M49TgsFullShadowEvidence
|
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
||||||
result={tgs}
|
Канонический M4.9 TGS слой недоступен: {tgsError}
|
||||||
semanticOverride={semantic}
|
</div>
|
||||||
evidenceLabel="LAB V1 · EoMT + DDRNet + YOLOX + TGS"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (route.linkedTgsResultId && !tgsError) {
|
if (!tgs) {
|
||||||
return (
|
return (
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||||
Открываем sealed EoMT, TGS и coarse vegetation timeline…
|
Открываем sealed EoMT, TGS и coarse vegetation timeline…
|
||||||
@@ -75,20 +221,11 @@ function VegetationRouteEvidence({ result }: { result: VegetationShadowResult })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<M49TgsFullShadowEvidence
|
||||||
<M4ReplayThreatVisual
|
result={tgs}
|
||||||
resultId={route.baseM4ResultId}
|
semanticOverride={semantic}
|
||||||
evidenceLabel="LAB V1 · DDRNet"
|
evidenceLabel="LAB V1 · EoMT + DDRNet + YOLOX + TGS"
|
||||||
showReferenceMediaLayers
|
|
||||||
showSpatialOverlaySummary={false}
|
|
||||||
semantic={semantic}
|
|
||||||
/>
|
/>
|
||||||
{tgsError ? (
|
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
|
||||||
TGS слой недоступен: {tgsError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +236,20 @@ export function VegetationShadowResultView({
|
|||||||
rigLabel: string;
|
rigLabel: string;
|
||||||
result: VegetationShadowResult;
|
result: VegetationShadowResult;
|
||||||
}) {
|
}) {
|
||||||
|
if (result.routeFullReview) {
|
||||||
|
return (
|
||||||
|
<FullRouteReviewResult
|
||||||
|
rigLabel={rigLabel}
|
||||||
|
resultId={result.resultId}
|
||||||
|
review={result.routeFullReview}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result.routeVideo?.linkedTgsResultId) {
|
||||||
|
throw new Error(
|
||||||
|
"Vegetation LAB result has no canonical M4 source timeline and linked M4.9 TGS evidence.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const route = result.routeVideo;
|
const route = result.routeVideo;
|
||||||
const selected = result.candidates.find(
|
const selected = result.candidates.find(
|
||||||
(candidate) => candidate.candidate === result.selectedCandidate,
|
(candidate) => candidate.candidate === result.selectedCandidate,
|
||||||
@@ -110,9 +261,7 @@ export function VegetationShadowResultView({
|
|||||||
<LaboratorySummary
|
<LaboratorySummary
|
||||||
title="LAB V1 · карта ровера · город + растительность"
|
title="LAB V1 · карта ровера · город + растительность"
|
||||||
description="Один recorded-контур RAVNOVES00 синхронно показывает городской EoMT, природный DDRNet, frozen YOLOX detections и causal TGS. Семантические маски переключаются, чтобы их цвета не скрывали друг друга; геометрическое veto остаётся независимым."
|
description="Один recorded-контур RAVNOVES00 синхронно показывает городской EoMT, природный DDRNet, frozen YOLOX detections и causal TGS. Семантические маски переключаются, чтобы их цвета не скрывали друг друга; геометрическое veto остаётся независимым."
|
||||||
status={route
|
status="MULTILAYER RECORDED REVIEW · commands OFF · route truth отсутствует"
|
||||||
? "MULTILAYER RECORDED REVIEW · commands OFF · route truth отсутствует"
|
|
||||||
: "ROUTE EVIDENCE MISSING · commands OFF"}
|
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
facts={[
|
facts={[
|
||||||
{ label: "Источник", value: "RAVNOVES00 · sensor.camera.right · 4489 recorded frames" },
|
{ label: "Источник", value: "RAVNOVES00 · sensor.camera.right · 4489 recorded frames" },
|
||||||
@@ -123,61 +272,31 @@ export function VegetationShadowResultView({
|
|||||||
]}
|
]}
|
||||||
brief={{
|
brief={{
|
||||||
question: "Можно ли одновременно видеть городской и природный semantic stack, не теряя независимую геометрическую защиту?",
|
question: "Можно ли одновременно видеть городской и природный semantic stack, не теряя независимую геометрическую защиту?",
|
||||||
approach: "EoMT и DDRNet сохранены как два независимых sealed слоя на одной M4 timeline. В штатном M4.7 viewer пользователь переключает только отображаемую маску; YOLOX и TGS остаются активными слоями evidence.",
|
approach: "EoMT и DDRNet сохранены как два независимых sealed слоя на одной M4 timeline. В штатном M4.9 viewer пользователь переключает только отображаемую маску; YOLOX и TGS остаются активными слоями evidence.",
|
||||||
principalResult: route
|
principalResult: "Оба semantic archive доступны в одном viewer. Это не пиксельный fusion и не единая новая модель: городской и природный ответы остаются раздельными.",
|
||||||
? "Оба semantic archive доступны в одном viewer. Это не пиксельный fusion и не единая новая модель: городской и природный ответы остаются раздельными."
|
|
||||||
: "Route archive для этой immutable identity отсутствует.",
|
|
||||||
limitation: "RAVNOVES00 не имеет ручной truth. DDRNet заметно прыгает между HIGH GRASS, WOODY и UNKNOWN; поэтому subtype нельзя подавать напрямую в planner. Отсутствие класса никогда не означает свободный путь.",
|
limitation: "RAVNOVES00 не имеет ручной truth. DDRNet заметно прыгает между HIGH GRASS, WOODY и UNKNOWN; поэтому subtype нельзя подавать напрямую в planner. Отсутствие класса никогда не означает свободный путь.",
|
||||||
}}
|
}}
|
||||||
method={{
|
method={{
|
||||||
completeness: route ? "complete" : "legacy-partial",
|
completeness: "complete",
|
||||||
executionClass: "ai-inference",
|
executionClass: "ai-inference",
|
||||||
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
|
pipelineId: "ravnoves-eomt-ddrnet-yolox-causal-tgs-recorded-review/v1",
|
||||||
components: [
|
components: [
|
||||||
{
|
{ kind: "model", name: "EoMT Cityscapes semantic", version: "sealed E47 archive", role: "urban semantic review", identitySha256: null },
|
||||||
kind: "model",
|
{ kind: "model", name: selected.loadedModelName, version: selected.candidate, role: "vegetation material candidate", identitySha256: selected.checkpointSha256 },
|
||||||
name: "EoMT Cityscapes semantic",
|
{ kind: "algorithm", name: "Frozen YOLOX + causal TGS", version: "linked M4/M4.9 archives", role: "independent object and geometry veto", identitySha256: null },
|
||||||
version: "sealed E47 archive",
|
|
||||||
role: "urban semantic review",
|
|
||||||
identitySha256: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "model",
|
|
||||||
name: selected.loadedModelName,
|
|
||||||
version: selected.candidate,
|
|
||||||
role: "vegetation material candidate",
|
|
||||||
identitySha256: selected.checkpointSha256,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "algorithm",
|
|
||||||
name: "Frozen YOLOX + causal TGS",
|
|
||||||
version: "linked M4/M4.9 archives",
|
|
||||||
role: "independent object and geometry veto",
|
|
||||||
identitySha256: null,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
evidence={route ? (
|
evidence={(
|
||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO"
|
eyebrow="M4.9 · RAVNOVES00 FULL VIDEO"
|
||||||
title="EoMT CITY / DDRNet VEGETATION + YOLOX + CAUSAL TGS · 4489/4489 · TRUTH отсутствует"
|
title="EoMT CITY / DDRNet VEGETATION + YOLOX + CAUSAL TGS · 4489/4489 · TRUTH отсутствует"
|
||||||
kind="diagnostic-model"
|
kind="diagnostic-model"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
<VegetationRouteEvidence result={result} />
|
<VegetationRouteEvidence result={result} />
|
||||||
</LaboratoryEvidence>
|
</LaboratoryEvidence>
|
||||||
) : (
|
|
||||||
<LaboratoryEvidence
|
|
||||||
eyebrow="M4.7 · RAVNOVES00 FULL VIDEO"
|
|
||||||
title="ROUTE ARCHIVE отсутствует"
|
|
||||||
kind="diagnostic-model"
|
|
||||||
>
|
|
||||||
<div className="m4-replay-threat-visual__pane-status" role="alert">
|
|
||||||
Для этой immutable identity нет полного route video evidence.
|
|
||||||
</div>
|
|
||||||
</LaboratoryEvidence>
|
|
||||||
)}
|
)}
|
||||||
result={(
|
result={(
|
||||||
<LaboratoryResultSummary
|
<LaboratoryResultSummary
|
||||||
@@ -185,26 +304,10 @@ export function VegetationShadowResultView({
|
|||||||
status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
|
status="Semantics advisory · YOLOX/TGS veto cannot be cleared"
|
||||||
statusTone="warning"
|
statusTone="warning"
|
||||||
metrics={[
|
metrics={[
|
||||||
{
|
{ label: "Route masks", value: `${route.frameCount}/${route.frameCount}`, hint: "sealed local playback · Worker для открытия не нужен" },
|
||||||
label: "Route masks",
|
{ label: "Semantic sources", value: "2 independent layers", hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse" },
|
||||||
value: route ? `${route.frameCount}/${route.frameCount}` : "0/4489",
|
{ label: "Vegetation worker p95", value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`, hint: "изолированный DDRNet inference; не совместный realtime stack" },
|
||||||
hint: "sealed local playback · Worker для открытия не нужен",
|
{ label: "Vegetation peak VRAM", value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`, hint: "DDRNet candidate на Worker 006" },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Semantic sources",
|
|
||||||
value: route ? "2 independent layers" : "0",
|
|
||||||
hint: "EoMT CITY / DDRNet VEGETATION · display switches, evidence does not fuse",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vegetation worker p95",
|
|
||||||
value: `${decimal(selected.shadowLatencyP95Ms, 2)} ms`,
|
|
||||||
hint: "изолированный DDRNet inference; не совместный realtime stack",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Vegetation peak VRAM",
|
|
||||||
value: `${decimal(selected.peakReservedVramBytes / 1024 ** 3, 2)} GiB`,
|
|
||||||
hint: "DDRNet candidate на Worker 006",
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
conclusion={{
|
conclusion={{
|
||||||
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
|
proved: "На одной recorded timeline доступны городской EoMT, природный DDRNet, YOLOX detections и causal TGS; LAB автономна от Worker.",
|
||||||
|
|||||||
+4
-2
@@ -10,6 +10,7 @@ interface M48EvidenceModeControlProps {
|
|||||||
mode: M48BlindEvidenceMode;
|
mode: M48BlindEvidenceMode;
|
||||||
cameraVisible: boolean;
|
cameraVisible: boolean;
|
||||||
spatialAvailable: boolean;
|
spatialAvailable: boolean;
|
||||||
|
planAvailable?: boolean;
|
||||||
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
||||||
onCameraVisibleChange: (visible: boolean) => void;
|
onCameraVisibleChange: (visible: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ export function M48EvidenceModeControls({
|
|||||||
mode,
|
mode,
|
||||||
cameraVisible,
|
cameraVisible,
|
||||||
spatialAvailable,
|
spatialAvailable,
|
||||||
|
planAvailable = spatialAvailable,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
onCameraVisibleChange,
|
onCameraVisibleChange,
|
||||||
}: M48EvidenceModeControlProps) {
|
}: M48EvidenceModeControlProps) {
|
||||||
@@ -68,9 +70,9 @@ export function M48EvidenceModeControls({
|
|||||||
<IconButton
|
<IconButton
|
||||||
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
||||||
aria-pressed={spatialMode === "plan"}
|
aria-pressed={spatialMode === "plan"}
|
||||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
|
disabled={!planAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!spatialAvailable) return;
|
if (!planAvailable) return;
|
||||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { L34RightYoloxTruthIslandResult } from "../../../core/laboratory/l34RightYoloxTruthIsland";
|
import type { L34RightYoloxTruthIslandResult } from "../../../core/laboratory/l34RightYoloxTruthIsland";
|
||||||
import type { L34DResult } from "../../../core/laboratory/l34dCumulativePostprocessing";
|
import type { L34DResult } from "../../../core/laboratory/l34dCumulativePostprocessing";
|
||||||
@@ -28,19 +28,19 @@ export function useL34AnnotationCapability({
|
|||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const openWorkspace = useCallback(() => setOpen(true), []);
|
const openWorkspace = useCallback(() => setOpen(true), []);
|
||||||
const available = selectedWorkId === "l34-right-yolox-truth-island-freeze"
|
const available = useMemo(() => (
|
||||||
&& l34Result
|
selectedWorkId === "l34-right-yolox-truth-island-freeze" && l34Result
|
||||||
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
? { resultId: l34Result.resultId, workflow: "assisted-candidate" as const }
|
||||||
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
: selectedWorkId === "e46-detector-truth-island" && e46Result
|
||||||
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
? { resultId: e46Result.resultId, workflow: "independent-blind" as const }
|
||||||
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
: selectedWorkId === "e46a-ai-engineering-preannotation" && e46aResult
|
||||||
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
? { resultId: e46aResult.resultId, workflow: "engineering-preannotation" as const }
|
||||||
: selectedWorkId === "l34d-cumulative-postprocessing-candidate"
|
: selectedWorkId === "l34d-cumulative-postprocessing-candidate" && l34dResult
|
||||||
&& l34dResult
|
|
||||||
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
? { resultId: l34dResult.resultId, workflow: "prediction-hidden" as const }
|
||||||
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
: selectedWorkId === "l34e-self-review-diagnostic" && l34eResult
|
||||||
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
? { resultId: l34eResult.resultId, workflow: "adjudication" as const }
|
||||||
: null;
|
: null
|
||||||
|
), [e46Result, e46aResult, l34Result, l34dResult, l34eResult, selectedWorkId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!available) {
|
if (!available) {
|
||||||
|
|||||||
@@ -11,11 +11,30 @@ const CHUNK_SIZE = 24;
|
|||||||
const RETAINED_CHUNK_COUNT = 8;
|
const RETAINED_CHUNK_COUNT = 8;
|
||||||
const PREFETCH_CHUNKS_AHEAD = 2;
|
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||||
|
|
||||||
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] {
|
export function e47SemanticChunkWindowStarts(
|
||||||
return Array.from(
|
activeStart: number,
|
||||||
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
frameCount: number,
|
||||||
(_, index) => activeStart + (index - 1) * CHUNK_SIZE,
|
): readonly number[] {
|
||||||
).filter((start) => start >= 0 && start < frameCount);
|
return [
|
||||||
|
activeStart,
|
||||||
|
...Array.from(
|
||||||
|
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||||
|
(_, index) => activeStart + (index + 1) * CHUNK_SIZE,
|
||||||
|
),
|
||||||
|
activeStart - CHUNK_SIZE,
|
||||||
|
].filter((start) => start >= 0 && start < frameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelE47SemanticRequestsOutsideWindow<T extends { abort(): void }>(
|
||||||
|
inFlight: Map<number, T>,
|
||||||
|
desiredStarts: readonly number[],
|
||||||
|
): void {
|
||||||
|
const desired = new Set(desiredStarts);
|
||||||
|
for (const [start, controller] of inFlight) {
|
||||||
|
if (desired.has(start)) continue;
|
||||||
|
controller.abort();
|
||||||
|
inFlight.delete(start);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
@@ -29,11 +48,13 @@ export function useE47SemanticTimelineFrame({
|
|||||||
activeSequence,
|
activeSequence,
|
||||||
frameCount,
|
frameCount,
|
||||||
taxonomy,
|
taxonomy,
|
||||||
|
enabled = true,
|
||||||
}: {
|
}: {
|
||||||
resultId: string | null;
|
resultId: string | null;
|
||||||
activeSequence: number | null;
|
activeSequence: number | null;
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
taxonomy: readonly E47SemanticClass[];
|
taxonomy: readonly E47SemanticClass[];
|
||||||
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
@@ -55,16 +76,21 @@ export function useE47SemanticTimelineFrame({
|
|||||||
for (const controller of inFlight.current.values()) controller.abort();
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
inFlight.current.clear();
|
inFlight.current.clear();
|
||||||
};
|
};
|
||||||
}, [resultId]);
|
}, [enabled, resultId]);
|
||||||
|
|
||||||
const activeStart = activeSequence === null
|
const activeStart = !enabled || activeSequence === null
|
||||||
? null
|
? null
|
||||||
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
||||||
activeStartRef.current = activeStart;
|
activeStartRef.current = activeStart;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resultId || activeStart === null || frameCount < 1) return;
|
if (!enabled || !resultId || activeStart === null || frameCount < 1) {
|
||||||
for (const start of chunkWindowStarts(activeStart, frameCount)) {
|
cancelE47SemanticRequestsOutsideWindow(inFlight.current, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const starts = e47SemanticChunkWindowStarts(activeStart, frameCount);
|
||||||
|
cancelE47SemanticRequestsOutsideWindow(inFlight.current, starts);
|
||||||
|
for (const start of starts) {
|
||||||
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
inFlight.current.set(start, controller);
|
inFlight.current.set(start, controller);
|
||||||
@@ -95,8 +121,11 @@ export function useE47SemanticTimelineFrame({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
||||||
});
|
});
|
||||||
|
// Semantic point arrays are large JSON payloads. Admit the active chunk
|
||||||
|
// first, then advance the bounded prefetch window one request per render.
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}, [activeStart, frameCount, resultId, taxonomy]);
|
}, [activeStart, chunks, enabled, frameCount, resultId, taxonomy]);
|
||||||
|
|
||||||
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
|
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
|
||||||
if (activeSequence === null || activeStart === null) return null;
|
if (activeSequence === null || activeStart === null) return null;
|
||||||
@@ -107,7 +136,7 @@ export function useE47SemanticTimelineFrame({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
activeFrame,
|
activeFrame,
|
||||||
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
loading: enabled && Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
|
|
||||||
const REQUESTED_CHUNK_FRAMES = 24;
|
const REQUESTED_CHUNK_FRAMES = 24;
|
||||||
const RETAINED_CHUNK_COUNT = 4;
|
const RETAINED_CHUNK_COUNT = 4;
|
||||||
|
const RETAINED_CHUNKS_BEHIND = 1;
|
||||||
const PREFETCH_CHUNKS_AHEAD = 1;
|
const PREFETCH_CHUNKS_AHEAD = 1;
|
||||||
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
const RETAINED_CAMERA_POINT_OVERLAYS = 12;
|
||||||
|
|
||||||
@@ -31,10 +32,17 @@ export function m4ThreatChunkWindowStarts(
|
|||||||
frameCount: number,
|
frameCount: number,
|
||||||
): readonly number[] {
|
): readonly number[] {
|
||||||
if (chunkSize < 1 || frameCount < 1) return [];
|
if (chunkSize < 1 || frameCount < 1) return [];
|
||||||
return Array.from(
|
return [
|
||||||
{ length: PREFETCH_CHUNKS_AHEAD + 1 },
|
activeChunkStart,
|
||||||
(_, index) => activeChunkStart + index * chunkSize,
|
...Array.from(
|
||||||
).filter((start) => start >= 0 && start < frameCount);
|
{ length: PREFETCH_CHUNKS_AHEAD },
|
||||||
|
(_, index) => activeChunkStart + (index + 1) * chunkSize,
|
||||||
|
),
|
||||||
|
...Array.from(
|
||||||
|
{ length: RETAINED_CHUNKS_BEHIND },
|
||||||
|
(_, index) => activeChunkStart - (index + 1) * chunkSize,
|
||||||
|
),
|
||||||
|
].filter((start) => start >= 0 && start < frameCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
|
export function cancelM4ThreatChunkRequestsOutsideWindow<T extends { abort(): void }>(
|
||||||
@@ -76,12 +84,16 @@ export function useM4ThreatTimelineFrame({
|
|||||||
resultId,
|
resultId,
|
||||||
timeline,
|
timeline,
|
||||||
currentSeconds,
|
currentSeconds,
|
||||||
|
includeSpatialPoints = true,
|
||||||
endpointRoot,
|
endpointRoot,
|
||||||
|
spatialPlaybackTransport = "auto",
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
timeline: M4ThreatTimeline | null;
|
timeline: M4ThreatTimeline | null;
|
||||||
currentSeconds: number;
|
currentSeconds: number;
|
||||||
|
includeSpatialPoints?: boolean;
|
||||||
endpointRoot?: string;
|
endpointRoot?: string;
|
||||||
|
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
|
||||||
}) {
|
}) {
|
||||||
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
@@ -94,8 +106,10 @@ export function useM4ThreatTimelineFrame({
|
|||||||
totalBytes: 0,
|
totalBytes: 0,
|
||||||
});
|
});
|
||||||
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
const [playbackError, setPlaybackError] = useState<string | null>(null);
|
||||||
const binaryPlayback = endpointRoot === undefined
|
const binaryPlayback = spatialPlaybackTransport === "sealed-binary"
|
||||||
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT;
|
|| (spatialPlaybackTransport === "auto" && (
|
||||||
|
endpointRoot === undefined || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT
|
||||||
|
));
|
||||||
const inFlight = useRef(new Map<number, AbortController>());
|
const inFlight = useRef(new Map<number, AbortController>());
|
||||||
const chunksRef = useRef(chunks);
|
const chunksRef = useRef(chunks);
|
||||||
const activeChunkStartRef = useRef<number | null>(null);
|
const activeChunkStartRef = useRef<number | null>(null);
|
||||||
@@ -107,7 +121,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
setPlaybackError(null);
|
setPlaybackError(null);
|
||||||
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 });
|
||||||
if (!timeline) return () => controller.abort();
|
if (!timeline) return () => controller.abort();
|
||||||
if (!binaryPlayback) {
|
if (!binaryPlayback || !includeSpatialPoints) {
|
||||||
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
@@ -127,7 +141,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [binaryPlayback, endpointRoot, resultId, timeline]);
|
}, [binaryPlayback, endpointRoot, includeSpatialPoints, resultId, timeline]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const controller of inFlight.current.values()) controller.abort();
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
@@ -140,7 +154,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
for (const controller of inFlight.current.values()) controller.abort();
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
inFlight.current.clear();
|
inFlight.current.clear();
|
||||||
};
|
};
|
||||||
}, [resultId, timeline]);
|
}, [includeSpatialPoints, resultId, timeline]);
|
||||||
|
|
||||||
const activeSequence = useMemo(
|
const activeSequence = useMemo(
|
||||||
() => timeline
|
() => timeline
|
||||||
@@ -158,7 +172,11 @@ export function useM4ThreatTimelineFrame({
|
|||||||
activeChunkStartRef.current = activeChunkStart;
|
activeChunkStartRef.current = activeChunkStart;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return;
|
if (
|
||||||
|
!timeline
|
||||||
|
|| activeChunkStart === null
|
||||||
|
|| (binaryPlayback && includeSpatialPoints && !playbackManifest)
|
||||||
|
) return;
|
||||||
const starts = m4ThreatChunkWindowStarts(
|
const starts = m4ThreatChunkWindowStarts(
|
||||||
activeChunkStart,
|
activeChunkStart,
|
||||||
chunkSize,
|
chunkSize,
|
||||||
@@ -170,7 +188,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
inFlight.current.set(start, controller);
|
inFlight.current.set(start, controller);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const playbackPointPack = binaryPlayback && playbackManifest
|
const playbackPointPack = binaryPlayback && includeSpatialPoints && playbackManifest
|
||||||
? await fetchM4ThreatPlaybackPointChunk(
|
? await fetchM4ThreatPlaybackPointChunk(
|
||||||
playbackManifest,
|
playbackManifest,
|
||||||
Math.floor(start / playbackManifest.chunkFrameCount),
|
Math.floor(start / playbackManifest.chunkFrameCount),
|
||||||
@@ -189,6 +207,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
endpointRoot,
|
endpointRoot,
|
||||||
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery,
|
||||||
playbackPointPack,
|
playbackPointPack,
|
||||||
|
includePoints: includeSpatialPoints,
|
||||||
});
|
});
|
||||||
})()
|
})()
|
||||||
.then((chunk) => {
|
.then((chunk) => {
|
||||||
@@ -220,7 +239,7 @@ export function useM4ThreatTimelineFrame({
|
|||||||
// loaded first, then the next chunk is prefetched on the following render.
|
// loaded first, then the next chunk is prefetched on the following render.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]);
|
}, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, includeSpatialPoints, playbackManifest, resultId, timeline]);
|
||||||
|
|
||||||
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => {
|
||||||
if (activeSequence === null || activeChunkStart === null) return null;
|
if (activeSequence === null || activeChunkStart === null) return null;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let canonicalMapGravityLocalPointToBodyGround;
|
||||||
|
let canonicalRecordedLabPackedTgsCells;
|
||||||
|
let canonicalRecordedLabTgsIsCurrent;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
canonicalMapGravityLocalPointToBodyGround,
|
||||||
|
canonicalRecordedLabPackedTgsCells,
|
||||||
|
canonicalRecordedLabTgsIsCurrent,
|
||||||
|
} = await server.ssrLoadModule("/src/core/laboratory/canonicalRecordedLab.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const identity = [
|
||||||
|
[1, 0, 0],
|
||||||
|
[0, 1, 0],
|
||||||
|
[0, 0, 1],
|
||||||
|
];
|
||||||
|
|
||||||
|
test("canonical TGS validity never retains a sparse anchor beyond its sealed history", () => {
|
||||||
|
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_000, 1_000_000_000), true);
|
||||||
|
assert.equal(canonicalRecordedLabTgsIsCurrent(2_000_000_001, 1_000_000_000), false);
|
||||||
|
assert.equal(canonicalRecordedLabTgsIsCurrent(999_999_999, 1_000_000_000), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("map-gravity-local TGS uses sensor translation and current ground body exactly once", () => {
|
||||||
|
const anchor = {
|
||||||
|
originMapXyzM: [10, 20, 0.68],
|
||||||
|
sensorOriginMapXyzM: [10, 20, 1],
|
||||||
|
basisMapFromBody: identity,
|
||||||
|
};
|
||||||
|
const current = {
|
||||||
|
originMapXyzM: [8, 20, 0],
|
||||||
|
sensorOriginMapXyzM: [8, 20, 0.32],
|
||||||
|
basisMapFromBody: identity,
|
||||||
|
};
|
||||||
|
assert.deepEqual(
|
||||||
|
canonicalMapGravityLocalPointToBodyGround([1, 2, -1], anchor, current),
|
||||||
|
[3, 2, 0],
|
||||||
|
);
|
||||||
|
const packed = canonicalRecordedLabPackedTgsCells({
|
||||||
|
centersXyM: [[1, 2]],
|
||||||
|
stateCodes: [2],
|
||||||
|
zBoundsM: [[-1, 0]],
|
||||||
|
}, anchor, current);
|
||||||
|
assert.deepEqual([...packed.centersBodyXyM], [3, 2]);
|
||||||
|
assert.deepEqual([...packed.zBoundsM], [0, 1]);
|
||||||
|
assert.deepEqual([...packed.stateCodes], [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recorded LAB spatial loading is shared, profile-bound and experiment-neutral", async () => {
|
||||||
|
const [contract, scheduler, vegetation] = await Promise.all([
|
||||||
|
readFile(new URL("../src/core/laboratory/canonicalRecordedLabSpatial.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/components/laboratory/useCanonicalRecordedLabSpatialFrame.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/core/laboratory/vegetationShadow.ts", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(contract, /CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
|
||||||
|
assert.match(contract, /profile: CANONICAL_RECORDED_LAB_SPATIAL_PROFILE/);
|
||||||
|
assert.match(scheduler, /Shared latest-request-wins scheduler/);
|
||||||
|
assert.match(scheduler, /identityRef\.current !== requestIdentity/);
|
||||||
|
assert.doesNotMatch(scheduler, /RAVNOVES|vegetation|DDRNet/);
|
||||||
|
assert.doesNotMatch(vegetation, /fetchCanonicalRecordedLabSpatialFrame|CanonicalRecordedLabSpatialFrame/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let laboratoryRecordedEvidenceDemand;
|
||||||
|
let e47SemanticChunkWindowStarts;
|
||||||
|
let cancelE47SemanticRequestsOutsideWindow;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({ laboratoryRecordedEvidenceDemand } = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/recordedEvidenceProfile.ts",
|
||||||
|
));
|
||||||
|
({
|
||||||
|
e47SemanticChunkWindowStarts,
|
||||||
|
cancelE47SemanticRequestsOutsideWindow,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
|
"/src/workspaces/laboratory/useE47SemanticTimeline.ts",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("camera-only LAB presentation does not acquire hidden spatial or semantic payloads", () => {
|
||||||
|
assert.deepEqual(laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode: "video",
|
||||||
|
spatialMode: null,
|
||||||
|
showMediaSemantic: false,
|
||||||
|
showSpatialSemantic: true,
|
||||||
|
showMediaPoints: false,
|
||||||
|
classifiedSpatialMode: "overlay",
|
||||||
|
}), {
|
||||||
|
sourceTimelineMetadata: true,
|
||||||
|
recordedVideo: true,
|
||||||
|
exactCameraFrame: false,
|
||||||
|
sourceSpatialPoints: false,
|
||||||
|
cameraPointOverlay: false,
|
||||||
|
selectedSemanticMask: false,
|
||||||
|
selectedSemanticPoints: false,
|
||||||
|
classifiedSpatial: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("visible M4 layers acquire only their selected camera and spatial evidence", () => {
|
||||||
|
assert.deepEqual(laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode: "camera",
|
||||||
|
spatialMode: "plan",
|
||||||
|
showMediaSemantic: true,
|
||||||
|
showSpatialSemantic: true,
|
||||||
|
showMediaPoints: true,
|
||||||
|
classifiedSpatialMode: "overlay",
|
||||||
|
}), {
|
||||||
|
sourceTimelineMetadata: true,
|
||||||
|
recordedVideo: false,
|
||||||
|
exactCameraFrame: true,
|
||||||
|
sourceSpatialPoints: true,
|
||||||
|
cameraPointOverlay: true,
|
||||||
|
selectedSemanticMask: true,
|
||||||
|
selectedSemanticPoints: true,
|
||||||
|
classifiedSpatial: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a classified replacement does not also load the hidden source point track", () => {
|
||||||
|
const demand = laboratoryRecordedEvidenceDemand({
|
||||||
|
mediaMode: null,
|
||||||
|
spatialMode: "3d",
|
||||||
|
showMediaSemantic: false,
|
||||||
|
showSpatialSemantic: false,
|
||||||
|
showMediaPoints: false,
|
||||||
|
classifiedSpatialMode: "replace-source",
|
||||||
|
});
|
||||||
|
assert.equal(demand.sourceSpatialPoints, false);
|
||||||
|
assert.equal(demand.classifiedSpatial, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("semantic point chunks are ordered active-first and stale requests are cancelled", () => {
|
||||||
|
assert.deepEqual(e47SemanticChunkWindowStarts(48, 4_489), [48, 72, 96, 24]);
|
||||||
|
assert.deepEqual(e47SemanticChunkWindowStarts(0, 4_489), [0, 24, 48]);
|
||||||
|
|
||||||
|
const cancelled = [];
|
||||||
|
const requests = new Map([
|
||||||
|
[0, { abort: () => cancelled.push(0) }],
|
||||||
|
[24, { abort: () => cancelled.push(24) }],
|
||||||
|
[240, { abort: () => cancelled.push(240) }],
|
||||||
|
]);
|
||||||
|
cancelE47SemanticRequestsOutsideWindow(requests, [240, 264]);
|
||||||
|
assert.deepEqual(cancelled, [0, 24]);
|
||||||
|
assert.deepEqual([...requests.keys()], [240]);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ let createLiveViewerDiagnosticLifecycle;
|
|||||||
let createAbortFencedBuildVerifier;
|
let createAbortFencedBuildVerifier;
|
||||||
let createUiBuildStaleCoordinator;
|
let createUiBuildStaleCoordinator;
|
||||||
let liveViewerDiagnosticBody;
|
let liveViewerDiagnosticBody;
|
||||||
|
let reloadRecordedViewerAfterStaleModuleFailure;
|
||||||
let server;
|
let server;
|
||||||
let uiBuildIdFromModuleScripts;
|
let uiBuildIdFromModuleScripts;
|
||||||
let verifyLiveViewerClientBuild;
|
let verifyLiveViewerClientBuild;
|
||||||
@@ -22,6 +23,7 @@ before(async () => {
|
|||||||
createLiveViewerDiagnosticLifecycle,
|
createLiveViewerDiagnosticLifecycle,
|
||||||
createUiBuildStaleCoordinator,
|
createUiBuildStaleCoordinator,
|
||||||
liveViewerDiagnosticBody,
|
liveViewerDiagnosticBody,
|
||||||
|
reloadRecordedViewerAfterStaleModuleFailure,
|
||||||
uiBuildIdFromModuleScripts,
|
uiBuildIdFromModuleScripts,
|
||||||
verifyLiveViewerClientBuild,
|
verifyLiveViewerClientBuild,
|
||||||
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
} = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
|
||||||
@@ -204,6 +206,36 @@ test("build drift is reported once with the exact loaded build", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a recorded viewer reloads only when its failed lazy module belongs to a stale build", async () => {
|
||||||
|
const reloads = [];
|
||||||
|
const loadedUiBuildId = "/assets/index-abcdefgh.js";
|
||||||
|
const currentUiBuildId = "/assets/index-ijklmnop.js";
|
||||||
|
const fetcher = async (_url, options) => {
|
||||||
|
assert.equal(options.headers["X-MissionCore-UI-Build"], loadedUiBuildId);
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
|
||||||
|
loadedUiBuildId,
|
||||||
|
fetcher,
|
||||||
|
reload: () => reloads.push("reload"),
|
||||||
|
}), true);
|
||||||
|
assert.deepEqual(reloads, ["reload"]);
|
||||||
|
|
||||||
|
assert.equal(await reloadRecordedViewerAfterStaleModuleFailure({
|
||||||
|
loadedUiBuildId: currentUiBuildId,
|
||||||
|
fetcher: async () => new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "X-MissionCore-UI-Build": currentUiBuildId },
|
||||||
|
}),
|
||||||
|
reload: () => reloads.push("unexpected"),
|
||||||
|
}), false);
|
||||||
|
assert.deepEqual(reloads, ["reload"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("last unsubscribe fences an already queued build verification callback", () => {
|
test("last unsubscribe fences an already queued build verification callback", () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const observedSignals = [];
|
const observedSignals = [];
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ let nextM48ObjectId;
|
|||||||
let laboratoryMetricLegendEntries;
|
let laboratoryMetricLegendEntries;
|
||||||
let nearestLaboratoryRecordedClipFrame;
|
let nearestLaboratoryRecordedClipFrame;
|
||||||
let laboratoryRecordedClipEndExclusiveNs;
|
let laboratoryRecordedClipEndExclusiveNs;
|
||||||
|
let laboratoryRecordedClipClockGate;
|
||||||
let m48SpatialPlaybackWindow;
|
let m48SpatialPlaybackWindow;
|
||||||
let trimM48SpatialPlaybackCache;
|
let trimM48SpatialPlaybackCache;
|
||||||
let nextM48CameraVisibility;
|
let nextM48CameraVisibility;
|
||||||
@@ -45,6 +46,7 @@ before(async () => {
|
|||||||
({
|
({
|
||||||
nearestLaboratoryRecordedClipFrame,
|
nearestLaboratoryRecordedClipFrame,
|
||||||
laboratoryRecordedClipEndExclusiveNs,
|
laboratoryRecordedClipEndExclusiveNs,
|
||||||
|
laboratoryRecordedClipClockGate,
|
||||||
} = await server.ssrLoadModule(
|
} = await server.ssrLoadModule(
|
||||||
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
||||||
));
|
));
|
||||||
@@ -342,6 +344,21 @@ test("shared recorded clip clock selects exact frames and one stable loop bounda
|
|||||||
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
|
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shared recorded clip rejects stale media callbacks until an explicit seek lands", () => {
|
||||||
|
assert.deepEqual(laboratoryRecordedClipClockGate(1, 123), {
|
||||||
|
accept: false,
|
||||||
|
pendingSequence: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(laboratoryRecordedClipClockGate(1, 1), {
|
||||||
|
accept: true,
|
||||||
|
pendingSequence: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(laboratoryRecordedClipClockGate(null, 124), {
|
||||||
|
accept: true,
|
||||||
|
pendingSequence: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
|
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
|
||||||
assert.equal(nextM48CameraVisibility("camera", true), true);
|
assert.equal(nextM48CameraVisibility("camera", true), true);
|
||||||
assert.equal(nextM48CameraVisibility("3d", true), false);
|
assert.equal(nextM48CameraVisibility("3d", true), false);
|
||||||
|
|||||||
@@ -379,14 +379,16 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
|
||||||
const pointOffsets = [0, ...Array(4489).fill(2)];
|
const playbackResultId = `lab-v1-vegetation-shadow-${"b".repeat(64)}`;
|
||||||
|
const frameCount = 48;
|
||||||
|
const pointOffsets = [0, ...Array(frameCount).fill(2)];
|
||||||
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
|
||||||
const pointBytes = points.buffer;
|
const pointBytes = points.buffer;
|
||||||
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
|
||||||
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
const endpointRoot = "/api/v1/laboratory/m4-threat/results";
|
||||||
const chunks = Array.from({ length: 188 }, (_, index) => {
|
const chunks = Array.from({ length: Math.ceil(frameCount / 24) }, (_, index) => {
|
||||||
const start = index * 24;
|
const start = index * 24;
|
||||||
const count = Math.min(24, 4489 - start);
|
const count = Math.min(24, frameCount - start);
|
||||||
const pointStart = pointOffsets[start];
|
const pointStart = pointOffsets[start];
|
||||||
const pointStop = pointOffsets[start + count];
|
const pointStop = pointOffsets[start + count];
|
||||||
const pointCount = pointStop - pointStart;
|
const pointCount = pointStop - pointStart;
|
||||||
@@ -396,7 +398,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
count,
|
count,
|
||||||
point_start: pointStart,
|
point_start: pointStart,
|
||||||
point_count: pointCount,
|
point_count: pointCount,
|
||||||
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`,
|
url: `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/${index}`,
|
||||||
media_type: "application/octet-stream",
|
media_type: "application/octet-stream",
|
||||||
dtype: "<f4",
|
dtype: "<f4",
|
||||||
shape: [pointCount, 3],
|
shape: [pointCount, 3],
|
||||||
@@ -406,8 +408,8 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
});
|
});
|
||||||
const manifestPayload = {
|
const manifestPayload = {
|
||||||
schema_version: "missioncore.recorded-spatial-playback/v1",
|
schema_version: "missioncore.recorded-spatial-playback/v1",
|
||||||
result_id: resultId,
|
result_id: playbackResultId,
|
||||||
frame_count: 4489,
|
frame_count: frameCount,
|
||||||
point_count: 2,
|
point_count: 2,
|
||||||
point_offsets: pointOffsets,
|
point_offsets: pointOffsets,
|
||||||
chunk_frame_count: 24,
|
chunk_frame_count: 24,
|
||||||
@@ -416,7 +418,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
chunks,
|
chunks,
|
||||||
track: {
|
track: {
|
||||||
id: "points-map-f32",
|
id: "points-map-f32",
|
||||||
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`,
|
url: `${endpointRoot}/${playbackResultId}/timeline/playback/tracks/points-map-f32`,
|
||||||
media_type: "application/octet-stream",
|
media_type: "application/octet-stream",
|
||||||
dtype: "<f4",
|
dtype: "<f4",
|
||||||
shape: [2, 3],
|
shape: [2, 3],
|
||||||
@@ -434,12 +436,12 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
|
|||||||
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
};
|
};
|
||||||
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher });
|
const manifest = await fetchM4ThreatPlaybackManifest(playbackResultId, { fetcher });
|
||||||
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
|
||||||
|
|
||||||
assert.deepEqual(requested, [
|
assert.deepEqual(requested, [
|
||||||
`${endpointRoot}/${resultId}/timeline/playback`,
|
`${endpointRoot}/${playbackResultId}/timeline/playback`,
|
||||||
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`,
|
`${endpointRoot}/${playbackResultId}/timeline/playback/chunks/0`,
|
||||||
]);
|
]);
|
||||||
assert.equal(chunk.pointCount, 2);
|
assert.equal(chunk.pointCount, 2);
|
||||||
assert.equal(chunk.pointStart, 0);
|
assert.equal(chunk.pointStart, 0);
|
||||||
@@ -769,7 +771,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
test("M4.6 spatial buffering keeps the active and one future chunk", () => {
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72, 24]);
|
||||||
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -786,8 +788,8 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps",
|
|||||||
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
if (!inFlight.has(start)) inFlight.set(start, controller(start));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.deepEqual([...inFlight.keys()], [4488]);
|
assert.deepEqual([...inFlight.keys()], [4488, 4464]);
|
||||||
assert.deepEqual(aborted, [0, 24, 1488, 1512]);
|
assert.deepEqual(aborted, [0, 24, 1488, 1512, 1464]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
|
||||||
@@ -847,8 +849,9 @@ test("recorded VIDEO clock cannot reverse an explicit operator pause", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
|
test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => {
|
||||||
const [visual, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
|
const [visual, canonical, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([
|
||||||
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
|
readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
||||||
@@ -858,10 +861,11 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
||||||
assert.match(visual, /<RecordedEvidenceImageScene/);
|
assert.match(visual, /<RecordedEvidenceImageScene/);
|
||||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||||
assert.match(visual, /m4-replay-threat-visual__deck/);
|
assert.match(visual, /<CanonicalRecordedLabReplay/);
|
||||||
|
assert.match(canonical, /m4-replay-threat-visual__deck/);
|
||||||
assert.match(visual, /lastFrameRef/);
|
assert.match(visual, /lastFrameRef/);
|
||||||
assert.match(visual, /lastSpatialFrameRef/);
|
assert.match(visual, /lastSpatialFrameRef/);
|
||||||
assert.match(visual, /const spatialFrame = frame\?\.spatialAvailable/);
|
assert.match(visual, /const spatialFrame = currentSpatialFrame/);
|
||||||
assert.match(visual, /<ObservationTimeline/);
|
assert.match(visual, /<ObservationTimeline/);
|
||||||
assert.match(visual, /useM4ThreatTimelineFrame/);
|
assert.match(visual, /useM4ThreatTimelineFrame/);
|
||||||
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
|
assert.match(visual, /resolveObservationSessionReplay\(timeline\.recordedSourceSessionId/);
|
||||||
@@ -876,19 +880,33 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visual, /pointCloudOverlay=/);
|
assert.match(visual, /pointCloudOverlay=/);
|
||||||
assert.match(visual, /mediaMode/);
|
assert.match(visual, /mediaMode/);
|
||||||
assert.match(visual, /spatialMode/);
|
assert.match(visual, /spatialMode/);
|
||||||
assert.match(visual, /current === next \? null : next/);
|
assert.match(canonical, /current === next \? null : next/);
|
||||||
assert.match(visual, /data-split=\{splitView \? "true" : undefined\}/);
|
assert.match(canonical, /data-split=\{splitView \? "true" : undefined\}/);
|
||||||
assert.match(visual, /<SplitPane/);
|
assert.match(canonical, /<SplitPane/);
|
||||||
assert.match(visual, /primarySize=\{splitView \? splitPrimarySize : mediaMode \? 100 : 0\}/);
|
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||||
assert.match(visual, /resizable=\{splitView\}/);
|
assert.match(canonical, /secondary=\{spatialPane/);
|
||||||
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||||
assert.match(visual, /secondaryMode=\{\{/);
|
assert.match(canonical, /resizable=\{splitView\}/);
|
||||||
|
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||||
|
assert.match(canonical, /secondaryMode=\{\{/);
|
||||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||||
assert.match(visual, /clock: mediaMode === "video" \? "external" : "animation"/);
|
assert.match(visual, /clock: "external"/);
|
||||||
|
assert.match(visual, /useCanonicalRecordedLabReplayState/);
|
||||||
|
assert.match(canonical, /current === next \? null : next/);
|
||||||
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
|
assert.match(visual, /timelineFrame\.activeSequence \+ 1/);
|
||||||
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
|
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
|
||||||
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
|
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
|
||||||
assert.match(visual, /onPlayingRejected=\{\(\) => playbackController\.setPlaying\(false\)\}/);
|
assert.match(
|
||||||
|
await readFile(new URL("../src/components/laboratory/useRecordedEvidencePlayback.ts", import.meta.url), "utf8"),
|
||||||
|
/if \(clock === "animation"\) return;/,
|
||||||
|
);
|
||||||
|
assert.match(visual, /playbackAuthority="media"/);
|
||||||
|
assert.match(visual, /playbackTransport = "epoch-stream"/);
|
||||||
|
assert.match(visual, /playbackTransport=\{playbackTransport\}/);
|
||||||
|
assert.match(
|
||||||
|
await readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
|
||||||
|
/if \(playbackAuthority === "host"\) return;/,
|
||||||
|
);
|
||||||
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
|
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
|
||||||
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
|
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
|
||||||
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
|
assert.match(visualCss, /m4-replay-threat-visual__pane-toolbar\[data-pane-toolbar="media"\]/);
|
||||||
@@ -912,6 +930,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
|
||||||
assert.match(visualCss, /bottom: auto/);
|
assert.match(visualCss, /bottom: auto/);
|
||||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||||
|
assert.match(videoScene, /!playback\.playing && Math\.abs\(next\.currentSeconds - playback\.currentSeconds\) > 0\.35/);
|
||||||
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
assert.match(imageScene, /<RecordedEvidenceBoxOverlay/);
|
||||||
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
assert.match(imageScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
assert.match(videoScene, /<RecordedEvidencePointCloudOverlay/);
|
||||||
@@ -920,19 +939,20 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
assert.match(metricScene, /OrbitControls/);
|
assert.match(metricScene, /OrbitControls/);
|
||||||
assert.match(visual, /LOCAL SLAM/);
|
assert.match(visual, /LOCAL SLAM/);
|
||||||
assert.match(visual, /showLocalSurface/);
|
assert.match(visual, /showLocalSurface/);
|
||||||
|
assert.match(visual, /const latestAvailableSpatialFrame = \[\.\.\.timelineFrame\.availableFrames\][\s\S]*candidate\.spatialAvailable[\s\S]*candidate\.sequence <= timelineFrame\.activeSequence/);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? activeSpatialFrame\?\.pointCloudBodyXyzM[\s\S]*\?\? \[\]\}/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
/const classifiedSpatialFrame = hasClassifiedSpatialOutput[\s\S]*classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||||
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||||
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
||||||
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
||||||
assert.match(visual, /\{semantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
assert.match(visual, /\{activeSemantic \? \([\s\S]*>\s*SEMANTICS\s*<\/Button>/);
|
||||||
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
|
assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/);
|
||||||
assert.match(
|
assert.match(
|
||||||
visual,
|
visual,
|
||||||
@@ -951,12 +971,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
|
test("M4.6 keeps the recorded VIDEO player mounted across media mode toggles", async () => {
|
||||||
const visual = await readFile(
|
const [visual, canonical] = await Promise.all([
|
||||||
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
),
|
||||||
assert.match(visual, /const mediaPane = \(/);
|
readFile(
|
||||||
assert.match(visual, /hidden=\{!mediaMode\}/);
|
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
assert.match(canonical, /const mediaPane = \(/);
|
||||||
|
assert.match(canonical, /hidden=\{mediaMode === "none"\}/);
|
||||||
assert.match(visual, /data-media="video"/);
|
assert.match(visual, /data-media="video"/);
|
||||||
assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
|
assert.match(visual, /hidden=\{mediaMode !== "video"\}/);
|
||||||
assert.match(visual, /\{videoSource \? \(/);
|
assert.match(visual, /\{videoSource \? \(/);
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ let recordedMediaSeekableCoverage;
|
|||||||
let recordedMediaFragmentUrl;
|
let recordedMediaFragmentUrl;
|
||||||
let recordedMediaDecodeStartSequence;
|
let recordedMediaDecodeStartSequence;
|
||||||
let recordedMediaSegmentAppendOrder;
|
let recordedMediaSegmentAppendOrder;
|
||||||
|
let recordedMediaSegmentSequenceAtTime;
|
||||||
let recordedMediaCanRollTarget;
|
let recordedMediaCanRollTarget;
|
||||||
|
let recordedMediaTimestampStallRecoveryTarget;
|
||||||
|
let nextRecordedMediaRandomAccessSequence;
|
||||||
|
let recordedMediaRecoveryTargetSequence;
|
||||||
|
let selectRecordedMediaPreparationEpoch;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -26,7 +31,12 @@ before(async () => {
|
|||||||
recordedMediaFragmentUrl,
|
recordedMediaFragmentUrl,
|
||||||
recordedMediaDecodeStartSequence,
|
recordedMediaDecodeStartSequence,
|
||||||
recordedMediaSegmentAppendOrder,
|
recordedMediaSegmentAppendOrder,
|
||||||
|
recordedMediaSegmentSequenceAtTime,
|
||||||
recordedMediaCanRollTarget,
|
recordedMediaCanRollTarget,
|
||||||
|
recordedMediaTimestampStallRecoveryTarget,
|
||||||
|
nextRecordedMediaRandomAccessSequence,
|
||||||
|
recordedMediaRecoveryTargetSequence,
|
||||||
|
selectRecordedMediaPreparationEpoch,
|
||||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -161,7 +171,7 @@ test("decoded duration and seekable range cover the complete declared epoch", ()
|
|||||||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recorded player keeps full-archive range fallback and uses bounded generation-bound fragments", async () => {
|
test("production replay derives bounded fragments and retains native range fallback", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -176,6 +186,13 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
|||||||
assert.match(source, /hasPresentedFrame/);
|
assert.match(source, /hasPresentedFrame/);
|
||||||
assert.match(source, /retainForwardFrame/);
|
assert.match(source, /retainForwardFrame/);
|
||||||
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
|
assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/);
|
||||||
|
assert.match(source, /effectiveSegmentCount = segmentCount \?\? epoch\?\.segmentCount \?\? null/);
|
||||||
|
assert.match(source, /requestedSegmentSequence = segmentSequence \?\?/);
|
||||||
|
assert.match(source, /waitForRecordedVideoInitialFrame/);
|
||||||
|
assert.match(source, /setSegmentRecoveryGeneration/);
|
||||||
|
assert.match(source, /resumePlaybackIfRequested/);
|
||||||
|
assert.match(source, /!playbackPlayingRef\.current/);
|
||||||
|
assert.match(source, /await video\.play\(\)/);
|
||||||
|
|
||||||
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -209,6 +226,44 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("production replay derives its bounded fragment directly from the source clock", () => {
|
||||||
|
const ends = [0.101, 0.185, 0.286, 0.401];
|
||||||
|
const epochStart = 39.215263458;
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart), 1);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.101), 1);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.103), 2);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 0.4), 4);
|
||||||
|
assert.equal(recordedMediaSegmentSequenceAtTime(ends, epochStart, epochStart + 1), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a corrupt fragment advances recovery to the next random-access frame", () => {
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 1), 11);
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 20), 21);
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 49), null);
|
||||||
|
assert.equal(nextRecordedMediaRandomAccessSequence([1, 11, 21, 49], 0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("paused seek displays the recovery keyframe until the source clock leaves the corrupt GOP", () => {
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(120, 120, 149), 149);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(130, 120, 149), 149);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(119, 120, 149), 119);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(149, 120, 149), 149);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(151, 120, 149), 151);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(null, 120, 149), null);
|
||||||
|
assert.equal(recordedMediaRecoveryTargetSequence(120, null, null), 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("camera admission selects one bounded epoch while the shared clock is outside video", () => {
|
||||||
|
const epochs = [
|
||||||
|
{ ordinal: 1, timelineStartSeconds: 39, timelineEndSeconds: 100 },
|
||||||
|
{ ordinal: 2, timelineStartSeconds: 120, timelineEndSeconds: 180 },
|
||||||
|
];
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 0).ordinal, 1);
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 70).ordinal, 1);
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 110).ordinal, 2);
|
||||||
|
assert.equal(selectRecordedMediaPreparationEpoch(epochs, 200).ordinal, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
|
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
|
||||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
|
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
|
||||||
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
|
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
|
||||||
@@ -217,6 +272,12 @@ test("recorded player preserves forward rolling playback but seeks backward clip
|
|||||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recorded player skips only a proven buffered corrupt timestamp interval", () => {
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(11.422, [[0, 16.287]]), 11.602);
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(16.25, [[0, 16.287]]), null);
|
||||||
|
assert.equal(recordedMediaTimestampStallRecoveryTarget(20, [[0, 16.287]]), null);
|
||||||
|
});
|
||||||
|
|
||||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||||
const css = await readFile(
|
const css = await readFile(
|
||||||
new URL("../src/styles/observation.css", import.meta.url),
|
new URL("../src/styles/observation.css", import.meta.url),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function camera(phase, byteLength = 1_024) {
|
|||||||
return { phase, byteLength, message: null };
|
return { phase, byteLength, message: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
|
test("recorded session keeps camera and timeline atomic without hiding a ready spatial frame", () => {
|
||||||
const ids = ["camera.left", "camera.right"];
|
const ids = ["camera.left", "camera.right"];
|
||||||
const oneCamera = {
|
const oneCamera = {
|
||||||
"camera.left": camera("ready"),
|
"camera.left": camera("ready"),
|
||||||
@@ -42,7 +42,7 @@ test("recorded session admits RRD and every declared camera as one atomic genera
|
|||||||
};
|
};
|
||||||
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
|
||||||
assert.equal(partialGate, "loading");
|
assert.equal(partialGate, "loading");
|
||||||
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
|
assert.equal(rerunPresentationStatus("ready", partialGate, true), "ready");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
|
||||||
"loading",
|
"loading",
|
||||||
@@ -71,7 +71,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
|
|||||||
...cameras,
|
...cameras,
|
||||||
"camera.right": camera("ready"),
|
"camera.right": camera("ready"),
|
||||||
}), "error");
|
}), "error");
|
||||||
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
|
assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
|
||||||
"error",
|
"error",
|
||||||
|
|||||||
@@ -257,6 +257,14 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
|||||||
source,
|
source,
|
||||||
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/reloadRecordedViewerAfterStaleModuleFailure\(\{[\s\S]*loadedUiBuildId: diagnosticLifecycle\.lineage\.uiBuildId/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/!recordedPerceptionUrl \|\| !recordedPerceptionLayers\.enabled/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("one live document owns one native Rerun receiver", async () => {
|
test("one live document owns one native Rerun receiver", async () => {
|
||||||
@@ -287,8 +295,12 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
|||||||
source,
|
source,
|
||||||
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
|
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
|
||||||
);
|
);
|
||||||
assert.match(source, /followLive=\{liveRerunSource\}/);
|
assert.match(
|
||||||
assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/);
|
source,
|
||||||
|
/liveAcquisitionRerunProfile\(\{[\s\S]*liveActivitySequence: livePresentationActivitySequence/,
|
||||||
|
);
|
||||||
|
assert.match(source, /<RerunViewport[\s\S]*profile=\{rerunViewerProfile\}/);
|
||||||
|
assert.doesNotMatch(source, /followLive=\{liveRerunSource\}/);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ let isRecordedPlaybackPresentationReady;
|
|||||||
let isUsableRecordedPlaybackRange;
|
let isUsableRecordedPlaybackRange;
|
||||||
let recordedPlaybackBufferState;
|
let recordedPlaybackBufferState;
|
||||||
let recordedPlaybackRangeWhenReady;
|
let recordedPlaybackRangeWhenReady;
|
||||||
|
let rerunPresentationStatus;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -26,6 +27,7 @@ before(async () => {
|
|||||||
isUsableRecordedPlaybackRange,
|
isUsableRecordedPlaybackRange,
|
||||||
recordedPlaybackBufferState,
|
recordedPlaybackBufferState,
|
||||||
recordedPlaybackRangeWhenReady,
|
recordedPlaybackRangeWhenReady,
|
||||||
|
rerunPresentationStatus,
|
||||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ after(async () => {
|
|||||||
await server?.close();
|
await server?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
|
test("a verified first frame is presentable while full-range controls stay closed", () => {
|
||||||
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
assert.equal(isUsableRecordedPlaybackRange(null), false);
|
||||||
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
|
||||||
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
|
||||||
@@ -47,8 +49,11 @@ test("a first frame reports buffer telemetry but is not ready for presentation",
|
|||||||
bufferProgress: 0,
|
bufferProgress: 0,
|
||||||
fullyBuffered: false,
|
fullyBuffered: false,
|
||||||
});
|
});
|
||||||
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
|
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), true);
|
||||||
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
|
||||||
|
assert.equal(rerunPresentationStatus("ready", "loading", true), "ready");
|
||||||
|
assert.equal(rerunPresentationStatus("loading", "ready", true), "loading");
|
||||||
|
assert.equal(rerunPresentationStatus("ready", "error", true), "ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
test("host timeline remains unmounted until the verified recording is fully ready", () => {
|
||||||
@@ -107,7 +112,7 @@ test("buffer progress grows independently and preserves the full-buffer toleranc
|
|||||||
);
|
);
|
||||||
assert.equal(missingDeclaredStart.bufferProgress, 1);
|
assert.equal(missingDeclaredStart.bufferProgress, 1);
|
||||||
assert.equal(missingDeclaredStart.fullyBuffered, false);
|
assert.equal(missingDeclaredStart.fullyBuffered, false);
|
||||||
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), false);
|
assert.equal(isRecordedPlaybackReady(true, true, missingDeclaredStart), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a verified split boundary spill covers and clamps the declared LAB window", () => {
|
test("a verified split boundary spill covers and clamps the declared LAB window", () => {
|
||||||
|
|||||||
@@ -75,20 +75,29 @@ test("semantic point alignment follows the last qualified spatial increment", as
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("M4 keeps independent semantic controls in media and spatial panes", async () => {
|
test("M4 keeps independent semantic controls in media and spatial panes", async () => {
|
||||||
const source = await readFile(
|
const [source, canonical] = await Promise.all([
|
||||||
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
]);
|
||||||
assert.match(source, /showMediaSemantic/);
|
assert.match(source, /showMediaSemantic/);
|
||||||
assert.match(source, /showSpatialSemantic/);
|
assert.match(source, /showSpatialSemantic/);
|
||||||
assert.match(source, /semantic && showMediaSemantic && frame/);
|
assert.match(source, /activeSpatialSemantic = spatialSemantic \?\? activeSemantic/);
|
||||||
|
assert.match(source, /spatialSemanticClasses/);
|
||||||
|
assert.match(source, /spatialSemanticPalette/);
|
||||||
|
assert.match(source, /activeSemantic && evidenceDemand\.selectedSemanticMask && frame/);
|
||||||
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
|
assert.match(source, /Array\.from\(\{ length: 12 \}, \(_, index\) => index \+ 1\)/);
|
||||||
assert.match(source, /\|\| !showSpatialSemantic/);
|
assert.match(source, /\|\| !showSpatialSemantic/);
|
||||||
assert.match(source, /aria-label="Слои камеры и видео"/);
|
assert.match(source, /aria-label="Слои камеры и видео"/);
|
||||||
assert.match(source, /aria-label="Слои 3D и плана"/);
|
assert.match(source, /aria-label="Слои 3D и плана"/);
|
||||||
assert.match(source, /data-pane-mode="media"/);
|
assert.match(canonical, /data-pane-mode="media"/);
|
||||||
assert.match(source, /data-pane-mode="spatial"/);
|
assert.match(canonical, /data-pane-mode="spatial"/);
|
||||||
assert.match(source, /modeControlsVisible=\{!splitView\}/);
|
assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
|
||||||
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
|||||||
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/);
|
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/);
|
||||||
assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/);
|
assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/);
|
||||||
assert.match(runtime, /app\.autoRender = false/);
|
assert.match(runtime, /app\.autoRender = false/);
|
||||||
|
assert.match(runtime, /app\.systems\.gsplat\?\.on\("frame:request", this\.requestGsplatFrame\)/);
|
||||||
|
assert.match(runtime, /app\.systems\.gsplat\?\.off\("frame:request", this\.requestGsplatFrame\)/);
|
||||||
assert.match(runtime, /app\.on\("update", this\.updateFrame\)/);
|
assert.match(runtime, /app\.on\("update", this\.updateFrame\)/);
|
||||||
assert.match(runtime, /if \(!cameraChanged && this\.controlMode === "free"\) return/);
|
assert.match(runtime, /if \(!cameraChanged && this\.controlMode === "free"\) return/);
|
||||||
assert.doesNotMatch(runtime, /app\.on\("frameupdate"/);
|
assert.doesNotMatch(runtime, /app\.on\("frameupdate"/);
|
||||||
@@ -162,6 +164,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
|||||||
assert.match(ugv, /new this\.ammo\.btRaycastVehicle/);
|
assert.match(ugv, /new this\.ammo\.btRaycastVehicle/);
|
||||||
assert.match(ugv, /type: "mesh"/);
|
assert.match(ugv, /type: "mesh"/);
|
||||||
assert.match(ugv, /MeshoptSimplifier\.simplify/);
|
assert.match(ugv, /MeshoptSimplifier\.simplify/);
|
||||||
|
assert.match(ugv, /MeshoptSimplifier\.simplifySloppy/);
|
||||||
|
assert.match(ugv, /Physics proxy превысил лимит/);
|
||||||
assert.match(ugv, /PHYSICS_PROXY_MAX_TRIANGLES = 240_000/);
|
assert.match(ugv, /PHYSICS_PROXY_MAX_TRIANGLES = 240_000/);
|
||||||
assert.match(ugv, /createPhysicsProxyMesh/);
|
assert.match(ugv, /createPhysicsProxyMesh/);
|
||||||
assert.match(ugv, /findSafeSpawnPosition/);
|
assert.match(ugv, /findSafeSpawnPosition/);
|
||||||
@@ -179,8 +183,33 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
|||||||
assert.match(ugv, /desiredSpeed = forwardInput \* maxSpeed/);
|
assert.match(ugv, /desiredSpeed = forwardInput \* maxSpeed/);
|
||||||
assert.match(ugv, /maximumAcceleration = clamp\(1\.4 \+ maxSpeed \* 0\.35, 1\.8, 4\.2\)/);
|
assert.match(ugv, /maximumAcceleration = clamp\(1\.4 \+ maxSpeed \* 0\.35, 1\.8, 4\.2\)/);
|
||||||
assert.match(ugv, /SERVICE_BRAKE_DECELERATION_MPS2 = 1\.8/);
|
assert.match(ugv, /SERVICE_BRAKE_DECELERATION_MPS2 = 1\.8/);
|
||||||
assert.match(ugv, /horizontalSpeed - deceleration \* Math\.max\(0, deltaSeconds\)/);
|
assert.match(ugv, /PARKING_BRAKE_HOLD_DECELERATION_MPS2 = 6/);
|
||||||
assert.match(ugv, /this\.vehicle\.setBrake\(0, index\)/);
|
assert.match(ugv, /PARKING_BRAKE_ENGAGE_SPEED_MPS = 0\.08/);
|
||||||
|
assert.match(ugv, /TYRE_FRICTION_SLIP = 8\.5/);
|
||||||
|
assert.match(ugv, /TYRE_STATIC_FRICTION_COEFFICIENT = 0\.95/);
|
||||||
|
assert.match(ugv, /TYRE_KINETIC_FRICTION_COEFFICIENT = 0\.78/);
|
||||||
|
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND = 10/);
|
||||||
|
assert.match(ugv, /GRAVITY_METERS_PER_SECOND_SQUARED = 9\.81/);
|
||||||
|
assert.match(ugv, /holding = !braking && forwardInput === 0 && turnInput === 0/);
|
||||||
|
assert.match(ugv, /longitudinalSpeedMetersPerSecond/);
|
||||||
|
assert.match(ugv, /parkingBrakeEngaged = holding/);
|
||||||
|
assert.match(ugv, /this\.settings\.massKg \* brakeDeceleration/);
|
||||||
|
assert.match(ugv, /this\.vehicle\.setBrake\(wheelBrakeForce, index\)/);
|
||||||
|
assert.match(ugv, /set_m_frictionSlip\(\s*parkingBrakeEngaged \? 0 : TYRE_FRICTION_SLIP/);
|
||||||
|
assert.match(ugv, /applyParkingTyreContact/);
|
||||||
|
assert.match(ugv, /wheel\.get_m_wheelsSuspensionForce\(\)/);
|
||||||
|
assert.doesNotMatch(ugv, /get_m_isInContact/);
|
||||||
|
assert.match(ugv, /normalForce < MIN_TYRE_NORMAL_FORCE_NEWTONS/);
|
||||||
|
assert.match(ugv, /lateralGravityAcceleration/);
|
||||||
|
assert.match(ugv, /longitudinalGravityAcceleration/);
|
||||||
|
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND \* lateralSlipSpeed/);
|
||||||
|
assert.match(ugv, /TYRE_CONTACT_VELOCITY_RESPONSE_PER_SECOND \* longitudinalSlipSpeed/);
|
||||||
|
assert.match(ugv, /Math\.hypot\(trialLateralForce, trialLongitudinalForce\)/);
|
||||||
|
assert.match(ugv, /body\.applyImpulse\(this\.tyreImpulseNative, this\.tyreRelativePositionNative\)/);
|
||||||
|
assert.doesNotMatch(ugv, /horizontalSpeed - deceleration \* Math\.max\(0, deltaSeconds\)/);
|
||||||
|
assert.match(ugv, /rollingFriction: 0\.12/);
|
||||||
|
assert.match(ugv, /angularDamping: 0\.6/);
|
||||||
|
assert.match(ugv, /wheel\.set_m_frictionSlip\(TYRE_FRICTION_SLIP\)/);
|
||||||
assert.doesNotMatch(ugv, /massKg \* 3/);
|
assert.doesNotMatch(ugv, /massKg \* 3/);
|
||||||
assert.match(ugv, /pureTurn = !braking && forwardInput === 0 && turnInput !== 0/);
|
assert.match(ugv, /pureTurn = !braking && forwardInput === 0 && turnInput !== 0/);
|
||||||
assert.match(ugv, /desiredYawRate = -turnInput \* maxTurnRate/);
|
assert.match(ugv, /desiredYawRate = -turnInput \* maxTurnRate/);
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { createServer } from "vite";
|
|||||||
|
|
||||||
let server;
|
let server;
|
||||||
let fetchVegetationBenchmarkResult;
|
let fetchVegetationBenchmarkResult;
|
||||||
|
let fetchCanonicalRecordedLabSpatialFrame;
|
||||||
let fetchVegetationShadowResult;
|
let fetchVegetationShadowResult;
|
||||||
|
let fetchVegetationRouteTgsAnchor;
|
||||||
|
let vegetationFullRouteMaskUrl;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
@@ -14,9 +17,17 @@ before(async () => {
|
|||||||
logLevel: "silent",
|
logLevel: "silent",
|
||||||
server: { middlewareMode: true },
|
server: { middlewareMode: true },
|
||||||
});
|
});
|
||||||
({ fetchVegetationBenchmarkResult, fetchVegetationShadowResult } = await server.ssrLoadModule(
|
({
|
||||||
|
fetchVegetationBenchmarkResult,
|
||||||
|
fetchVegetationShadowResult,
|
||||||
|
fetchVegetationRouteTgsAnchor,
|
||||||
|
vegetationFullRouteMaskUrl,
|
||||||
|
} = await server.ssrLoadModule(
|
||||||
"/src/core/laboratory/vegetationShadow.ts",
|
"/src/core/laboratory/vegetationShadow.ts",
|
||||||
));
|
));
|
||||||
|
({ fetchCanonicalRecordedLabSpatialFrame } = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/canonicalRecordedLabSpatial.ts",
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@@ -148,6 +159,70 @@ function coarseRouteVideo() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fullRouteReview() {
|
||||||
|
const layer = (kind) => ({
|
||||||
|
name: kind === "city" ? "EoMT Cityscapes" : "ddrnet_39",
|
||||||
|
result_id: kind === "city"
|
||||||
|
? `result-${"2".repeat(64)}`
|
||||||
|
: `lab-v1-ravnoves-video-ddrnet-${"3".repeat(64)}`,
|
||||||
|
frame_count: 6830,
|
||||||
|
taxonomy: {
|
||||||
|
schema_version: kind === "city"
|
||||||
|
? "missioncore.recorded-eomt-taxonomy/v1"
|
||||||
|
: "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||||
|
classes: Array.from({ length: kind === "city" ? 16 : 64 }, (_, classId) => ({
|
||||||
|
class_id: classId,
|
||||||
|
label: classId === 0 ? "undefined" : `${kind}-${classId}`,
|
||||||
|
color_rgb: [classId, classId, classId],
|
||||||
|
disposition: classId === 0 ? "undefined" : "prediction",
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
mask_archive: {
|
||||||
|
path: kind === "city"
|
||||||
|
? "video/eomt-semantic-masks.zip"
|
||||||
|
: "video/ddrnet-semantic-masks.zip",
|
||||||
|
sha256: "4".repeat(64),
|
||||||
|
byte_length: 4096,
|
||||||
|
},
|
||||||
|
inference_fps: 9.5,
|
||||||
|
latency_p95_ms: 101.2,
|
||||||
|
peak_reserved_vram_bytes: 3_000_000_000,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
source_id: "RAVNOVES004TREE",
|
||||||
|
session_id: "20260828T130511Z_viewer_live",
|
||||||
|
linked_route_review_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||||
|
source_job_id: "recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
source_job_input_sha256: "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
|
||||||
|
source_stream_sha256: "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
recorded_media_source_id: "recorded.camera.6a3945242828a038",
|
||||||
|
recorded_media_generation_sha256: "b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded",
|
||||||
|
frame_count: 6830,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
timeline_start_seconds: 39.215263458,
|
||||||
|
timeline_end_seconds: 757.260263458,
|
||||||
|
timeline: {
|
||||||
|
path: "video/frame-source-times-ns.bin",
|
||||||
|
sha256: "5".repeat(64),
|
||||||
|
byte_length: 6830 * 8,
|
||||||
|
encoding: "uint64-le-nanoseconds",
|
||||||
|
frame_count: 6830,
|
||||||
|
},
|
||||||
|
ground_truth: false,
|
||||||
|
decode_repair: {
|
||||||
|
repaired_frame_count: 1,
|
||||||
|
sequence: 6092,
|
||||||
|
method: "duplicate-previous-decoded-frame",
|
||||||
|
proofs: {
|
||||||
|
eomt: { path: "proofs/decode_repair.json", sha256: "7".repeat(64) },
|
||||||
|
ddrnet: { path: "proofs/ddrnet_decode_repair.json", sha256: "8".repeat(64) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: { city: layer("city"), vegetation: layer("vegetation") },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function labPayload(route = routeVideo()) {
|
function labPayload(route = routeVideo()) {
|
||||||
return {
|
return {
|
||||||
schema_version: "missioncore.lab-v1-vegetation-shadow/v1",
|
schema_version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||||
@@ -233,6 +308,47 @@ test("vegetation LAB parses coarse material policy and sealed TGS binding", asyn
|
|||||||
assert.equal(result.routeVideo.fusionMode, "synchronised-multilayer-review");
|
assert.equal(result.routeVideo.fusionMode, "synchronised-multilayer-review");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("vegetation LAB parses the full 004 pass inside the existing result contract", async () => {
|
||||||
|
const payload = {
|
||||||
|
...labPayload(null),
|
||||||
|
catalogs: { goose: [], ravnoves: [] },
|
||||||
|
route_full_review: fullRouteReview(),
|
||||||
|
};
|
||||||
|
const timeline = new ArrayBuffer(6830 * 8);
|
||||||
|
const timelineView = new DataView(timeline);
|
||||||
|
for (let index = 0; index < 6830; index += 1) {
|
||||||
|
timelineView.setBigUint64(
|
||||||
|
index * 8,
|
||||||
|
BigInt(39_215_263_458 + index * 100_000_000),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await fetchVegetationShadowResult(resultId, {
|
||||||
|
fetcher: async (url) => String(url).endsWith("/route-timeline")
|
||||||
|
? new Response(timeline, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
ETag: `"${"5".repeat(64)}"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.routeVideo, null);
|
||||||
|
assert.equal(result.routeFullReview.frameCount, 6830);
|
||||||
|
assert.equal(result.routeFullReview.city.taxonomy.length, 16);
|
||||||
|
assert.equal(result.routeFullReview.vegetation.taxonomy.length, 64);
|
||||||
|
assert.equal(result.routeFullReview.decodeRepair.sequence, 6092);
|
||||||
|
assert.equal(result.routeFullReview.frameSourceTimesNs.length, 6830);
|
||||||
|
assert.equal(
|
||||||
|
vegetationFullRouteMaskUrl(resultId, "vegetation", 6829),
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${resultId}/route-masks/vegetation/6829`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("vegetation GOOSE benchmark opens through its separate archival endpoint", async () => {
|
test("vegetation GOOSE benchmark opens through its separate archival endpoint", async () => {
|
||||||
let requestedUrl = "";
|
let requestedUrl = "";
|
||||||
const result = await fetchVegetationBenchmarkResult(benchmarkResultId, {
|
const result = await fetchVegetationBenchmarkResult(benchmarkResultId, {
|
||||||
@@ -255,8 +371,88 @@ test("vegetation GOOSE benchmark opens through its separate archival endpoint",
|
|||||||
assert.equal(result.validationCases.length, 12);
|
assert.equal(result.validationCases.length, 12);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("vegetation route TGS anchor keeps exact sealed metric shapes", async () => {
|
||||||
|
let requestedUrl = "";
|
||||||
|
const anchor = await fetchVegetationRouteTgsAnchor(resultId, 409, {
|
||||||
|
fetcher: async (url) => {
|
||||||
|
requestedUrl = String(url);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.lab-v1-route-tgs-anchor/v1",
|
||||||
|
source_sequence: 409,
|
||||||
|
slot: 1,
|
||||||
|
current_points_xyz_m: [[1, 2, 3], [4, 5, 6]],
|
||||||
|
costmap: {
|
||||||
|
cell_size_m: 0.45,
|
||||||
|
centers_xy_m: Array.from({ length: 2244 }, (_, index) => [index, -index]),
|
||||||
|
state_codes: Array.from({ length: 2244 }, (_, index) => index % 4),
|
||||||
|
z_bounds_m: Array.from({ length: 2244 }, () => [null, null]),
|
||||||
|
},
|
||||||
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
requestedUrl,
|
||||||
|
`/api/v1/laboratory/vegetation-shadow/${resultId}/route-tgs-anchor/409`,
|
||||||
|
);
|
||||||
|
assert.equal(anchor.sourceSequence, 409);
|
||||||
|
assert.equal(anchor.currentPointsXyzM.length, 2);
|
||||||
|
assert.equal(anchor.costmap.centersXyM.length, 2244);
|
||||||
|
assert.deepEqual(new Set(anchor.costmap.stateCodes), new Set([0, 1, 2, 3]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canonical recorded LAB spatial frame keeps source, SLAM and body identity on one clock", async () => {
|
||||||
|
const generation = "e".repeat(64);
|
||||||
|
let requestedUrl = "";
|
||||||
|
const frame = await fetchCanonicalRecordedLabSpatialFrame("session-004", generation, 82_770_000_000, {
|
||||||
|
fetcher: async (url) => {
|
||||||
|
requestedUrl = String(url);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
|
target_time_ns: 82_770_000_000,
|
||||||
|
source_time_ns: 82_769_535_708,
|
||||||
|
pose_time_ns: 82_769_535_708,
|
||||||
|
trajectory_time_ns: 82_700_000_000,
|
||||||
|
coordinate_frame: "body-ground",
|
||||||
|
sensor_height: {
|
||||||
|
meters: 0.32,
|
||||||
|
source: "local-source-cloud-ground-quantile-median",
|
||||||
|
sample_count: 20,
|
||||||
|
mad_m: 0.03,
|
||||||
|
authority: "visual-derived",
|
||||||
|
},
|
||||||
|
spatial_profile: {
|
||||||
|
profile_id: "source-paced-ground-v3",
|
||||||
|
local_slam_history_seconds: 5,
|
||||||
|
local_slam_radius_m: 30,
|
||||||
|
local_slam_voxel_size_m: 0.12,
|
||||||
|
local_slam_point_limit: 27000,
|
||||||
|
},
|
||||||
|
source_point_count: 2,
|
||||||
|
source_points_body_xyz_m: [[1, 2, 3], [4, 5, 6]],
|
||||||
|
local_slam_source_frame_count: 2,
|
||||||
|
local_slam_source_point_count: 4,
|
||||||
|
local_slam_point_count: 2,
|
||||||
|
local_slam_body_xyz_m: [[0, 0, 0], [1, 0, 0]],
|
||||||
|
body_frame: {
|
||||||
|
origin_map_xyz_m: [33, 4, 1],
|
||||||
|
sensor_origin_map_xyz_m: [33, 4, 1.32],
|
||||||
|
basis_map_from_body: [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
|
||||||
|
},
|
||||||
|
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
requestedUrl,
|
||||||
|
`/api/v1/observation-sessions/session-004/canonical-lab/spatial-frame?generation=${generation}&time_ns=82770000000&profile=source-paced-ground-v3`,
|
||||||
|
);
|
||||||
|
assert.equal(frame.sourcePointCount, 2);
|
||||||
|
assert.equal(frame.localSlamBodyXyzM.length, 2);
|
||||||
|
assert.equal(frame.sensorHeight.meters, 0.32);
|
||||||
|
assert.deepEqual(frame.bodyFrame.originMapXyzM, [33, 4, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => {
|
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => {
|
||||||
const [resultSource, benchmarkSource] = await Promise.all([
|
const [resultSource, benchmarkSource, m49Source, canonicalSource] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -265,13 +461,40 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
|||||||
new URL("../src/workspaces/laboratory/VegetationBenchmarkResult.tsx", import.meta.url),
|
new URL("../src/workspaces/laboratory/VegetationBenchmarkResult.tsx", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
readFile(
|
||||||
|
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
||||||
assert.match(resultSource, /M4ReplayThreatVisual/);
|
|
||||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||||
assert.match(resultSource, /semanticOverride/);
|
assert.match(resultSource, /semanticOverride/);
|
||||||
assert.match(resultSource, /EoMT CITY \/ DDRNet VEGETATION/);
|
assert.match(m49Source, /spatialSemantic=\{spatialSemantic\}/);
|
||||||
|
assert.match(m49Source, /controlLabel: "SEMANTICS"/);
|
||||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||||
|
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
|
||||||
|
assert.match(resultSource, /CANONICAL RECORDED LAB · RAVNOVES004TREE/);
|
||||||
|
assert.match(resultSource, /<M4ReplayThreatVisual/);
|
||||||
|
assert.match(resultSource, /timelineEndpointRoot=\{VEGETATION_TIMELINE_ENDPOINT\}/);
|
||||||
|
assert.match(resultSource, /playbackTransport="segmented"/);
|
||||||
|
assert.match(resultSource, /recoverTimestampStalls/);
|
||||||
|
assert.doesNotMatch(resultSource, /RerunViewport/);
|
||||||
|
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
|
||||||
|
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
|
||||||
|
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
|
||||||
|
assert.match(resultSource, /SOURCE POINTS/);
|
||||||
|
assert.match(resultSource, /TGS COSTMAP/);
|
||||||
|
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||||
|
assert.match(resultSource, /cellLayerAvailable: false/);
|
||||||
|
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||||
|
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||||
|
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||||
|
assert.match(canonicalSource, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||||
|
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
|
||||||
assert.match(resultSource, /linkedTgsResultId/);
|
assert.match(resultSource, /linkedTgsResultId/);
|
||||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||||
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
|
assert.doesNotMatch(benchmarkSource, /M49TgsFullShadowEvidence/);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE;
|
||||||
|
let liveAcquisitionRerunProfile;
|
||||||
|
let recordedSessionRerunProfile;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||||
|
liveAcquisitionRerunProfile,
|
||||||
|
recordedSessionRerunProfile,
|
||||||
|
} = await server.ssrLoadModule("/src/core/observation/viewerProfile.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("live acquisition profile cannot acquire recorded playback policy", () => {
|
||||||
|
assert.deepEqual(liveAcquisitionRerunProfile({
|
||||||
|
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
|
||||||
|
liveActivitySequence: 12,
|
||||||
|
liveStreamId: "acquisition-1",
|
||||||
|
liveRecoveryAuthorityIdentity: "authority-1",
|
||||||
|
}), {
|
||||||
|
kind: "live-acquisition",
|
||||||
|
clock: "stream_time",
|
||||||
|
sourceUrl: "rerun+http://127.0.0.1:9877/proxy",
|
||||||
|
liveActivitySequence: 12,
|
||||||
|
liveStreamId: "acquisition-1",
|
||||||
|
liveRecoveryAuthorityIdentity: "authority-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recorded session profile owns progressive admission and on-demand layers", () => {
|
||||||
|
const artifact = {
|
||||||
|
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
|
||||||
|
viewerSourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd?generation=abc",
|
||||||
|
byteLength: 42,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
};
|
||||||
|
const profile = recordedSessionRerunProfile({
|
||||||
|
sourceUrl: artifact.sourceUrl,
|
||||||
|
artifact,
|
||||||
|
autoplayWhenReady: true,
|
||||||
|
presentationGate: "loading",
|
||||||
|
expectedTimelineStartSeconds: 0,
|
||||||
|
expectedTimelineEndSeconds: 20,
|
||||||
|
initialPlaybackStartSeconds: 1,
|
||||||
|
view: "spatial",
|
||||||
|
viewResetGeneration: 0,
|
||||||
|
followTrajectory: false,
|
||||||
|
perceptionLayers: {
|
||||||
|
enabled: false,
|
||||||
|
detections2d: false,
|
||||||
|
segmentation: false,
|
||||||
|
cuboids3d: false,
|
||||||
|
},
|
||||||
|
perceptionRetryGeneration: 0,
|
||||||
|
lockPerceptionCameraInteraction: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(profile.kind, "recorded-session");
|
||||||
|
assert.equal(profile.clock, "session_time");
|
||||||
|
assert.equal(profile.artifact, artifact);
|
||||||
|
assert.equal(profile.perceptionLayers.enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LAB recorded evidence remains outside native Rerun lifecycle", () => {
|
||||||
|
assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, {
|
||||||
|
kind: "lab-recorded-evidence",
|
||||||
|
clock: "source-sequence",
|
||||||
|
cameraTransport: "generation-bound-fmp4",
|
||||||
|
spatialTransport: "bounded-sealed-artifacts",
|
||||||
|
loadPolicy: "visible-evidence-only",
|
||||||
|
workerRequired: false,
|
||||||
|
});
|
||||||
|
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
||||||
|
});
|
||||||
@@ -291,7 +291,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"catalog_id": "lab-v1-vegetation-shadow",
|
"catalog_id": "lab-v1-vegetation-shadow",
|
||||||
"evidence_id": "lab-v1-vegetation-shadow-394a5bca860e49a619a90c0f267259fd17e550a0abc4b51f821b1f482040c00e",
|
"evidence_id": "lab-v1-vegetation-shadow-d179462134967ace1c5ebd6fbdbdd8659905d390484b9c01ea7930f083bb74d1",
|
||||||
"signal": "progress",
|
"signal": "progress",
|
||||||
"lifecycle": "current",
|
"lifecycle": "current",
|
||||||
"visual_evidence": "available"
|
"visual_evidence": "available"
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.lab-v1-ravnoves-source/v1",
|
||||||
|
"profile_id": "ravnoves004tree-full-video-source/v1",
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES004TREE/right-e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
"source_sha256": "e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac",
|
||||||
|
"source_job_id": "recorded-camera-eb2783c5480d56bda07c8af0",
|
||||||
|
"source_job_input_sha256": "eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715",
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"base_m4_result_id": null,
|
||||||
|
"expected_width": 800,
|
||||||
|
"expected_height": 600,
|
||||||
|
"expected_frame_count": 6830,
|
||||||
|
"timeline_start_seconds": 39.215263458,
|
||||||
|
"timeline_end_seconds": 757.260263458,
|
||||||
|
"frame_indices": [],
|
||||||
|
"crop_contract": "center-600-square-to-512; outside-crop-is-undefined"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.mixed-route-tgs-review-profile/v1",
|
||||||
|
"profile_id": "ravnoves004tree-mixed-route-tgs-review/v1",
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES004TREE",
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"review_pack_id": "mixed-route-review-pack-a8d245eb08a9581a994c4ae5ad242fec20f02c7c512c5ca5d3a6dd9464012753",
|
||||||
|
"source_pack_id": "mixed-route-lidar-pack-e3fe195588cc4a2ec17e15af6f46582ed71c9bed643943779c4ed5e565a3c839",
|
||||||
|
"source_pack_sha256": "10c759463da7711fbbe67e70df931597d85ab21325f7f8026e2c945b677e1bc6",
|
||||||
|
"input_coordinate_frame": "map-gravity-local-translation-only"
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"max_range_m": 80.0,
|
||||||
|
"min_range_m": 1.0,
|
||||||
|
"resolution_m": 8.0,
|
||||||
|
"num_iterations": 3,
|
||||||
|
"num_lowest_representative_points": 5,
|
||||||
|
"minimum_points": 10,
|
||||||
|
"seed_threshold_m": 0.5,
|
||||||
|
"distance_threshold_m": 0.125,
|
||||||
|
"outlier_threshold_m": 0.3,
|
||||||
|
"normal_threshold": 0.94,
|
||||||
|
"weight_threshold": 200.0,
|
||||||
|
"lcc_normal_similarity": 0.03,
|
||||||
|
"lcc_planar_distance_m": 0.1,
|
||||||
|
"obstacle_height_m": 1.0,
|
||||||
|
"refine_mode": true
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"current_increment": {
|
||||||
|
"role": "diagnostic-current-evidence"
|
||||||
|
},
|
||||||
|
"causal_rolling_1s": {
|
||||||
|
"role": "primary-local-evidence",
|
||||||
|
"history_seconds": 1.0,
|
||||||
|
"local_radius_m": 12.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"costmap": {
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"cell_size_m": 0.45,
|
||||||
|
"radius_m": 12.0,
|
||||||
|
"state_priority": [
|
||||||
|
"NONGROUND_OCCUPIED",
|
||||||
|
"UNKNOWN_REJECTED",
|
||||||
|
"GROUND_SUPPORT",
|
||||||
|
"UNOBSERVED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"state_codes": {
|
||||||
|
"UNOBSERVED": 0,
|
||||||
|
"GROUND_SUPPORT": 1,
|
||||||
|
"NONGROUND_OCCUPIED": 2,
|
||||||
|
"UNKNOWN_REJECTED": 3
|
||||||
|
},
|
||||||
|
"invariants": {
|
||||||
|
"all_eligible_input_points_accounted": true,
|
||||||
|
"aos_allowed": false,
|
||||||
|
"lidar_orientation_applied_to_tgs_input": false,
|
||||||
|
"map_gravity_axis_preserved": true,
|
||||||
|
"missing_support_means_free": false,
|
||||||
|
"unobserved_cells_are_emitted": true,
|
||||||
|
"camera_projection_is_authoritative": false,
|
||||||
|
"future_frames_used": false,
|
||||||
|
"gpu_allowed": false,
|
||||||
|
"navigation_or_actuation_allowed": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,3 +122,23 @@ Primary implementation references:
|
|||||||
- a ready project mounts direct PlayCanvas Engine and loads Streamed SOG with preview fallback;
|
- a ready project mounts direct PlayCanvas Engine and loads Streamed SOG with preview fallback;
|
||||||
- visual, collision and combined remain distinct runtime modes, and collision is loaded lazily;
|
- visual, collision and combined remain distinct runtime modes, and collision is loaded lazily;
|
||||||
- viewer quality, layer-axis correction and camera inversion survive navigation and reload.
|
- viewer quality, layer-axis correction and camera inversion survive navigation and reload.
|
||||||
|
|
||||||
|
## AI-assisted visual quality extension
|
||||||
|
|
||||||
|
AI-assisted visual repair is a candidate lifecycle of one ready Simulation World project. It does
|
||||||
|
not replace or modify the accepted ingest/optimization path. The original LCC/LCC2 bundle remains
|
||||||
|
the source of record, while an explicitly promoted enhanced generation may replace only the active
|
||||||
|
visual SOG references.
|
||||||
|
|
||||||
|
The product entry point is `Улучшить качество` in the existing project edit window. It opens a
|
||||||
|
canonical bounded Window for XGRIDS Creator Data admission, region-of-interest selection, actual
|
||||||
|
provider state and baseline/candidate review. It is not placed in the live scene controls because
|
||||||
|
source admission and a long-running candidate build are project operations. It does not receive a
|
||||||
|
separate workspace because the candidate has no identity outside its parent project.
|
||||||
|
|
||||||
|
The control is not shipped as a placeholder. It becomes available only with a ready project and an
|
||||||
|
enhancement provider publishing the accepted capability. The provider consumes full-quality PLY
|
||||||
|
derived from the immutable LCC/LCC2 source plus aligned Creator Data images/COLMAP cameras; SOG is
|
||||||
|
delivery output only. Generated visual regions remain forbidden as collision, navigation or
|
||||||
|
qualification evidence. The detailed boundary and first experiment are defined by
|
||||||
|
[ADR 0044](adr/0044-ai-assisted-gaussian-visual-repair.md).
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# ADR 0044: AI-assisted Gaussian visual repair remains a candidate pipeline
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Proposed for a Worker 006 ROI experiment on 2026-08-29. The existing Gaussian optimization and
|
||||||
|
collision paths remain accepted and unchanged. No repair model is admitted for production use by
|
||||||
|
this decision.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current Simulation Worlds vertical accepts one XGRIDS LCC/LCC2 source, builds preview and
|
||||||
|
streamed SOG artifacts through DC Gaussian Pipeline, and publishes source-mesh collision
|
||||||
|
independently. The two current ready projects prove that path. AutoCap repairs only conservative
|
||||||
|
holes in the source collision mesh; it does not repair the visual Gaussian representation.
|
||||||
|
|
||||||
|
The retained MAROSEYKA archive contains an LCC Quality scene with 86,471,152 splats and a
|
||||||
|
44,787-sample 10 Hz device trajectory. It does not contain the captured camera images, camera
|
||||||
|
intrinsics or camera extrinsics. The pose records do not identify RGB frames and cannot be treated
|
||||||
|
as calibrated camera poses.
|
||||||
|
|
||||||
|
AI repair methods that permanently improve a 3DGS model need rendered novel views, real reference
|
||||||
|
images and known cameras. Plausible generation alone cannot recover the factual appearance or
|
||||||
|
geometry of a surface that was never observed.
|
||||||
|
|
||||||
|
XGRIDS LCC Studio Creator Data provides the missing supported interchange boundary:
|
||||||
|
|
||||||
|
- `perspective/images/` contains undistorted perspective images;
|
||||||
|
- `perspective/masks/` contains invalid-region masks;
|
||||||
|
- `perspective/sparse/` contains COLMAP camera intrinsics and extrinsics aligned with the optimized
|
||||||
|
LiDAR point cloud;
|
||||||
|
- `poses.csv` and `high_frequency_poses.csv` retain device trajectories when they are useful for
|
||||||
|
selecting a repair corridor.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. The immutable original LCC/LCC2 bundle is the visual source of record. SOG is a compressed web
|
||||||
|
delivery artifact and is never the input to AI repair.
|
||||||
|
2. A repair candidate starts from full-quality `LCC/LCC2 -> standard 3DGS PLY` conversion. It may
|
||||||
|
operate only on bounded spatial tiles or route-derived regions of interest; loading or training
|
||||||
|
the complete 86M-splat scene as one model is not an accepted Worker 006 profile.
|
||||||
|
3. A repair request additionally admits XGRIDS Creator Data. The minimum input is undistorted
|
||||||
|
images plus a complete COLMAP sparse model. Masks are strongly preferred. Device poses alone do
|
||||||
|
not satisfy camera admission.
|
||||||
|
4. The first experiment uses FreeFix at an exact source revision with the SDXL refinement path. It
|
||||||
|
was selected because it is fine-tuning-free at the diffusion-model level, uses per-pixel
|
||||||
|
confidence to preserve reliable regions, reports outdoor/Waymo evaluation, and publishes MIT
|
||||||
|
code. An adapter must import standard XGRIDS/PlayCanvas PLY attributes into the gsplat checkpoint
|
||||||
|
layout and export a standard PLY after refinement.
|
||||||
|
5. Difix3D+ is the mandatory comparison baseline for the same ROI. It directly targets artifacts in
|
||||||
|
under-constrained views and supports progressive distillation into gsplat, but its combined
|
||||||
|
NVIDIA/Stability licensing needs a separate commercial-use review.
|
||||||
|
6. The experiment runs in an adjacent `ndc-` prefixed Docker composition on Worker 006. It shares
|
||||||
|
neither Python/CUDA environments nor writable model directories with DC Gaussian Pipeline. The
|
||||||
|
existing pipeline is called only after a candidate PLY is complete and immutable.
|
||||||
|
7. Every result is a candidate generation. Mission Core keeps the active visual generation until
|
||||||
|
an operator compares the baseline and candidate and explicitly promotes the candidate. Failure
|
||||||
|
or cancellation cannot modify the active scene.
|
||||||
|
8. Generated visual content never becomes collision, navigation, traversability or ground-truth
|
||||||
|
evidence. Existing source-mesh collision and AutoCap remain independent. Each candidate retains
|
||||||
|
an uncertainty/hallucination mask and exact model/config provenance.
|
||||||
|
|
||||||
|
## Candidate contract
|
||||||
|
|
||||||
|
A quality-enhancement request must bind all inputs by digest:
|
||||||
|
|
||||||
|
- project ID and original source-bundle SHA-256;
|
||||||
|
- LCC/LCC2 entrypoint and full-quality PLY conversion revision;
|
||||||
|
- Creator Data bundle SHA-256;
|
||||||
|
- COLMAP cameras, images and points model plus their coordinate-alignment report;
|
||||||
|
- selected route interval and/or world-space ROI;
|
||||||
|
- algorithm, source revision, model IDs/digests, prompt policy and numerical parameters.
|
||||||
|
|
||||||
|
The adjacent provider returns:
|
||||||
|
|
||||||
|
- a standard enhanced PLY for the selected tile or composed candidate;
|
||||||
|
- preview and streamed SOG artifacts created by the unchanged optimization pipeline;
|
||||||
|
- baseline/candidate camera-path renders and objective image metrics where held-out real views
|
||||||
|
exist;
|
||||||
|
- changed-region, confidence and generated-content masks;
|
||||||
|
- wall time, peak VRAM, source revision, image digest and terminal job state.
|
||||||
|
|
||||||
|
The provider states are transport-neutral and bounded: `queued`, `validating_source`,
|
||||||
|
`aligning_cameras`, `extracting_roi`, `importing_splats`, `refining`, `building_delivery`,
|
||||||
|
`evaluating`, `ready`, `failed`, and `cancelled`.
|
||||||
|
|
||||||
|
## Product placement
|
||||||
|
|
||||||
|
The action belongs in the existing project edit window as `Улучшить качество`, because it acts on
|
||||||
|
one durable world and does not create a new workspace. The action opens a canonical bounded Window
|
||||||
|
that owns Creator Data admission, ROI choice, actual provider state and baseline/candidate review.
|
||||||
|
It is shown only for a ready outdoor/interior project and becomes an executable action only when
|
||||||
|
the enhancement provider publishes the exact accepted capability.
|
||||||
|
|
||||||
|
Alternatives considered:
|
||||||
|
|
||||||
|
1. Add controls to the live PlayCanvas scene. Rejected because source admission and a long-running
|
||||||
|
candidate lifecycle are project operations, not per-frame runtime controls.
|
||||||
|
2. Add a new top-level workspace. Rejected because repair has no independent identity outside one
|
||||||
|
Simulation World project.
|
||||||
|
3. Add an always-enabled button before provider/source admission exists. Rejected because it would
|
||||||
|
be placeholder product UI and would misrepresent the current system state.
|
||||||
|
|
||||||
|
## Experiment acceptance
|
||||||
|
|
||||||
|
The first ROI experiment is accepted only when:
|
||||||
|
|
||||||
|
- the adjacent composition starts and stops without changing the current Gaussian Pipeline
|
||||||
|
containers or native SplatTransform spool;
|
||||||
|
- one Creator Data bundle passes COLMAP/image/alignment validation;
|
||||||
|
- one bounded road-and-facade ROI fits the RTX 4090 24 GB profile without host OOM or impact on the
|
||||||
|
active optimization queue;
|
||||||
|
- FreeFix and Difix3D+ run on identical admitted cameras and ROI;
|
||||||
|
- candidate output round-trips through standard PLY and the existing SOG build;
|
||||||
|
- held-out views and an operator review show improvement without unacceptable changes in reliable
|
||||||
|
regions;
|
||||||
|
- active SOG and collision artifacts remain byte-identical until explicit promotion.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The first useful input request is XGRIDS Creator Data, not a raw device track and not SOG.
|
||||||
|
- Existing ready RAR archives cannot start factual image-conditioned repair by themselves.
|
||||||
|
- Large scenes require ROI/tile scheduling, overlap blending and candidate composition.
|
||||||
|
- Visually plausible fill may be valuable for rendering while remaining inadmissible as physical
|
||||||
|
truth.
|
||||||
|
- Worker deployment is intentionally blocked until a Creator Data sample and restored Worker 006
|
||||||
|
provider connectivity are available.
|
||||||
|
|
||||||
|
## Primary references
|
||||||
|
|
||||||
|
- [XGRIDS Creator Data](https://docs.xgrids.com/en-us/06-lixel-cybercolor/01-lcc-studio/v2.3.0/06-model-reconstruction.html#creator-data-and-nvidia-ncore-data)
|
||||||
|
- [PlayCanvas SplatTransform](https://github.com/playcanvas/splat-transform)
|
||||||
|
- [FreeFix](https://github.com/hyzhou404/FreeFix)
|
||||||
|
- [Difix3D+](https://github.com/nv-tlabs/Difix3D)
|
||||||
|
- [GSFix3D](https://github.com/GSFix3D/GSFix3D)
|
||||||
|
- [ArtifactWorld](https://github.com/fyting/ArtifactWorld)
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -0,0 +1,199 @@
|
|||||||
|
# RAVNOVES004TREE canonical LAB replay audit
|
||||||
|
|
||||||
|
Date: 2026-08-30
|
||||||
|
|
||||||
|
Scope: Mission Core recorded LAB replay, RAVNOVES004TREE, OPS perception state
|
||||||
|
|
||||||
|
Excluded: Gaussian/simulation workers and their artifacts
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
RAVNOVES004TREE no longer owns a custom LAB viewer. It supplies recording and
|
||||||
|
model configuration to the same `M4ReplayThreatVisual` and
|
||||||
|
`CanonicalRecordedLabReplay` implementation used by the accepted recorded LAB.
|
||||||
|
No new window or status type was added. The stable interaction contract remains:
|
||||||
|
|
||||||
|
- media: `SEMANTICS`, model selector, `VIDEO` / `CAMERA`;
|
||||||
|
- spatial: `SOURCE POINTS`, `LOCAL SLAM`, `TGS COSTMAP`, `SEMANTICS`, `3D` / `PLAN`;
|
||||||
|
- one timeline, one resizable split and one media-owned playback clock.
|
||||||
|
|
||||||
|
Models, result IDs, endpoints, labels and replay transport are configuration.
|
||||||
|
Window structure, switching, seek, buffering and spatial scene code are shared.
|
||||||
|
|
||||||
|
## Why the previous LAB failed
|
||||||
|
|
||||||
|
### RAV004 did not use the accepted replay data plane
|
||||||
|
|
||||||
|
The working M4/Hologravity LAB uses sealed binary numeric tracks, retained scene
|
||||||
|
state and bounded JSON metadata. RAV004 reused the visual component but silently
|
||||||
|
left its custom timeline endpoint on the JSON fallback. Each eight-frame spatial
|
||||||
|
window therefore transferred approximately 1.2-2.9 MB and took 0.87-1.37 s to
|
||||||
|
produce while representing only about 0.84 s of playback. The next request
|
||||||
|
aborted and replaced the previous one before spatial state could catch up.
|
||||||
|
|
||||||
|
The user screenshot captured the failure exactly: camera frame 366 was active
|
||||||
|
while the last delivered spatial evidence was frame 230, a 136-frame gap. The
|
||||||
|
backend also reopened and indexed the 6830-entry semantic ZIP for every requested
|
||||||
|
mask and proposal frame. This explains why the same canonical viewer was smooth
|
||||||
|
for Hologravity but stalled for RAV004: the window and interaction code were
|
||||||
|
shared, but the data-plane contract was not.
|
||||||
|
|
||||||
|
RAV004 now publishes the same `missioncore.recorded-spatial-playback/v1`
|
||||||
|
contract as the accepted LAB: an immutable Float32 map-point track, camera-frame
|
||||||
|
offsets and 24-frame binary chunks. JSON chunks contain bounded frame metadata
|
||||||
|
only; retained Local SLAM is reconstructed from exact source increments in the
|
||||||
|
shared client. The semantic ZIP handle and member index are cached per immutable
|
||||||
|
artifact instead of being reparsed per frame.
|
||||||
|
|
||||||
|
### Video and spatial state had different clocks
|
||||||
|
|
||||||
|
The removed RAV004 viewer advanced an animation/host clock even when the browser
|
||||||
|
decoder stopped. The point cloud therefore continued while the camera frame and
|
||||||
|
timeline could remain frozen. The shared viewer now uses the decoded media time
|
||||||
|
as the external clock, and image masks/boxes are rendered only when their time is
|
||||||
|
within 250 ms of the actually presented video time.
|
||||||
|
|
||||||
|
The RAV004 MP4 itself is not clean. An independent `ffmpeg` decode around the
|
||||||
|
reproducible stop at 11.422 s reported non-monotonic DTS values and corrupt H.264
|
||||||
|
macroblocks. The RAV004 profile therefore uses the shared segmented MSE transport
|
||||||
|
and an opt-in timestamp recovery rule. Recovery is allowed only when all of these
|
||||||
|
conditions are true:
|
||||||
|
|
||||||
|
- playback is requested and the media element is not paused, ended or seeking;
|
||||||
|
- decoded media time has not advanced by 20 ms for at least 1.25 s;
|
||||||
|
- the browser reports decoded media buffered ahead of the frozen timestamp.
|
||||||
|
|
||||||
|
Only then is the broken timestamp interval skipped by 180 ms. The media clock
|
||||||
|
immediately remains authoritative; the host does not free-run. A stale callback
|
||||||
|
from the old MSE window is also prevented from undoing an operator seek.
|
||||||
|
|
||||||
|
### LiDAR orientation inherited the wrong axes
|
||||||
|
|
||||||
|
The RRD declares `/world` as RFU (`Right`, `Forward`, `Up`) and logs
|
||||||
|
`/world/points` in map space. The earlier adapter treated raw LiDAR quaternion
|
||||||
|
columns as rover forward/left/up and inherited sensor roll/pitch. That is why the
|
||||||
|
grid, rover and facade could visibly disagree.
|
||||||
|
|
||||||
|
The v3 adapter now uses:
|
||||||
|
|
||||||
|
- map `+Z` as gravity/up;
|
||||||
|
- the smoothed pose-trajectory tangent projected onto the ground as forward;
|
||||||
|
- `left = up × forward`;
|
||||||
|
- projected sensor `+Y` only as a fallback when the tangent is unavailable.
|
||||||
|
|
||||||
|
This is a deterministic coordinate contract, not a visual angle correction.
|
||||||
|
|
||||||
|
### Sensor height was treated as a constant
|
||||||
|
|
||||||
|
RAV004 does not have a stable 0.4 m mounting height throughout the recording.
|
||||||
|
The adapter now estimates the local ground plane from a causal one-second
|
||||||
|
near-field point window and uses the sealed session estimate only as fallback.
|
||||||
|
Observed local heights include approximately 0.17 m, 1.24 m, 1.05 m and 0.22 m
|
||||||
|
at different route positions; a single hand-entered value is therefore invalid.
|
||||||
|
|
||||||
|
### Sparse LiDAR frames were held incorrectly
|
||||||
|
|
||||||
|
Camera is approximately 9.51 Hz while source points arrive at approximately
|
||||||
|
2 Hz. A camera frame without a new LiDAR increment used to retain whichever
|
||||||
|
spatial frame happened to finish loading last; under fast playback this could be
|
||||||
|
dozens of seconds old. The buffer now loads the active chunk first, the preceding
|
||||||
|
chunk second and the next chunk as prefetch. The scene selects the latest proven
|
||||||
|
source increment whose sequence is not later than the active camera frame.
|
||||||
|
|
||||||
|
At the final UI check, playback restarted at frame 1, then ran continuously past
|
||||||
|
frame 462. At frame 188 the DDRNet mask was frame 188, proposals were frame 187
|
||||||
|
and spatial state was delivered without buffering; the one-frame proposal delay
|
||||||
|
is the recorded causal overlay, not stale UI state. Before the transport fix the
|
||||||
|
user's run had already fallen 136 frames behind by camera frame 366.
|
||||||
|
|
||||||
|
## Capability ledger
|
||||||
|
|
||||||
|
| Layer | RAV004 full route | UI behavior | Authority |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| Recorded RIGHT camera | 6830/6830 | `VIDEO` / `CAMERA`, segmented playback | recorded evidence |
|
||||||
|
| DDRNet semantic mask | 6830/6830 | selectable, opaque enough for review | diagnostic prediction |
|
||||||
|
| EoMT semantic mask | 6830/6830 | selectable | diagnostic prediction |
|
||||||
|
| Diagnostic object boxes | derived from connected EoMT mask components | media-time gated | not an independent detector |
|
||||||
|
| Source points | 1444 increments | `SOURCE POINTS` | recorded geometry |
|
||||||
|
| Bounded Local SLAM | causal 5 s / 27k-point limit | `LOCAL SLAM` | visual-derived |
|
||||||
|
| Full-route TGS | **absent** | canonical `TGS COSTMAP` control is visible but disabled | unavailable, fail closed |
|
||||||
|
| Point-aligned 3D semantics | **absent** | canonical `SEMANTICS` control is visible but disabled | unavailable |
|
||||||
|
| Independent person/vehicle detector | **absent** | no STOP claim | unavailable |
|
||||||
|
|
||||||
|
Ten old TGS review anchors exist, but they are not a continuous route artifact.
|
||||||
|
They are not repeated or held as if they were full TGS. The accepted RAVNOVES00
|
||||||
|
full-TGS result is also not reused because it has a different source identity and
|
||||||
|
4489-frame timeline.
|
||||||
|
|
||||||
|
## Performance evidence
|
||||||
|
|
||||||
|
Measured on the canonical local service and current immutable artifacts:
|
||||||
|
|
||||||
|
- replay launch POST: 3.55 s on first opening;
|
||||||
|
- first binary playback-manifest build after a service restart: 32.33 s while
|
||||||
|
the process-local RRD point track is materialized; warm manifest: 0.18-0.20 s;
|
||||||
|
- full retained point track: 3,893,445 Float32 map points, 46,721,340 bytes,
|
||||||
|
divided into 285 immutable 24-frame chunks;
|
||||||
|
- representative active binary chunks: 155-224 KB at 9-10 ms;
|
||||||
|
- representative 24-frame metadata chunks: 24-32 KB at 0.78-1.0 s, covering
|
||||||
|
about 2.4 s of playback;
|
||||||
|
- cached semantic-mask reads: 5.6-7.8 ms instead of approximately 80 ms;
|
||||||
|
- UI replay: reset to frame 1 and ran continuously beyond frame 462 with camera,
|
||||||
|
semantic overlay and retained spatial state advancing together;
|
||||||
|
- operator reset seek: successful, one mounted media worker;
|
||||||
|
- browser console after the acceptance run: no warnings or errors.
|
||||||
|
|
||||||
|
The first RRD index is still process-local rather than a persistent disk cache.
|
||||||
|
That is an explicit remaining performance gap; warm playback is the admitted
|
||||||
|
profile, cold restart latency is not yet accepted.
|
||||||
|
|
||||||
|
## Nature perception: current OPS stopping point
|
||||||
|
|
||||||
|
OPS card `MISSIONCOR-65` defines the intended independent layers as EoMT,
|
||||||
|
DDRNet, frozen YOLOX and TGS. The current immutable RAV004 artifact proves full
|
||||||
|
EoMT and DDRNet inference only. It does not prove full TGS, negative-obstacle
|
||||||
|
handling, an independent person/vehicle STOP layer or combined real-time load.
|
||||||
|
|
||||||
|
Isolated full-route measurements:
|
||||||
|
|
||||||
|
- DDRNet-39: p95 27.44 ms, 52.67 inference FPS, validation mean IoU 29.715%,
|
||||||
|
vegetation mean IoU 0.3701;
|
||||||
|
- EoMT: p95 361.62 ms, approximately 3.01 inference FPS;
|
||||||
|
- prior accepted RAVNOVES00 TGS: p95 1.694 ms CPU-only, but this is algorithm
|
||||||
|
performance on another source, not RAV004 proof.
|
||||||
|
|
||||||
|
The DDRNet isolated throughput is sufficient for a 10 FPS budget. DDRNet is not
|
||||||
|
accepted for driving policy because temporal stability and nature quality are
|
||||||
|
not sufficient: the OPS temporal sample recorded adjacent-frame IoU near 0.195
|
||||||
|
for high grass and 0.400 for woody vegetation. EoMT does not meet 10 FPS in its
|
||||||
|
current form. The next evidentiary milestone is therefore not another UI model
|
||||||
|
toggle; it is synchronized truth for grass/tree/ditch/drop-off, full TGS and
|
||||||
|
negative-obstacle evidence, frozen independent detector output and a combined
|
||||||
|
load test at at least 10 FPS.
|
||||||
|
|
||||||
|
Worker 006 was audited read-only. Triton and the Gaussian containers were left
|
||||||
|
untouched. The separate Mission Core perception worker is currently in a restart
|
||||||
|
loop (404 during model inference startup); this audit did not stop, recreate or
|
||||||
|
deploy it.
|
||||||
|
|
||||||
|
## Acceptance performed
|
||||||
|
|
||||||
|
- 12 focused backend spatial/API tests passed;
|
||||||
|
- 16 focused frontend replay transport/manifest tests passed;
|
||||||
|
- TypeScript project typecheck passed;
|
||||||
|
- production Vite build passed (only existing large-chunk warnings);
|
||||||
|
- `git diff --check` passed;
|
||||||
|
- live browser run verified reset seek, the shared controls, disabled unsealed
|
||||||
|
TGS/3D semantics, continuous playback through the former failing interval,
|
||||||
|
causal spatial hold and a clean console.
|
||||||
|
|
||||||
|
Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`.
|
||||||
|
|
||||||
|
## External coordinate and media references
|
||||||
|
|
||||||
|
- Rerun ViewCoordinates: <https://rerun.io/docs/reference/types/datatypes/view_coordinates>
|
||||||
|
- Rerun transform relation: <https://rerun.io/docs/reference/types/components/transform_relation>
|
||||||
|
- Rerun transforms: <https://rerun.io/docs/concepts/logging-and-ingestion/transforms>
|
||||||
|
- Rerun Transform3D: <https://rerun.io/docs/reference/types/archetypes/transform3d>
|
||||||
|
- WHATWG media element model: <https://html.spec.whatwg.org/multipage/media.html>
|
||||||
|
- W3C Media Source Extensions: <https://www.w3.org/TR/media-source-2/>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish LiDAR/pose evidence aligned to an immutable mixed-route review pack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fuse_e6_tracking_lidar import CameraAnchor, _lidar_samples
|
||||||
|
|
||||||
|
from k1link.compute.jobs import validate_camera_compute_job
|
||||||
|
from k1link.device_plugins.xgrids_k1.analyze.calibrated_overlay import (
|
||||||
|
_load_calibration_snapshot,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||||
|
Kb4ProjectionProfile,
|
||||||
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.streams import decode_lio_pcl
|
||||||
|
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.mixed-route-lidar-pack/v1"
|
||||||
|
REVIEW_SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
MAXIMUM_LIDAR_CAMERA_DELTA_MS = 100.0
|
||||||
|
MAXIMUM_POSE_POINT_DELTA_MS = 100.0
|
||||||
|
CAUSAL_HISTORY_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class MixedRouteLidarPackError(RuntimeError):
|
||||||
|
"""The recorded route cannot satisfy the selected LiDAR evidence contract."""
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--job", type=Path, required=True)
|
||||||
|
parser.add_argument("--session", type=Path, required=True)
|
||||||
|
parser.add_argument("--review-pack", type=Path, required=True)
|
||||||
|
parser.add_argument("--calibration", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_review_pack(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
resolved = root.resolve(strict=True)
|
||||||
|
manifest_path = resolved / "manifest.json"
|
||||||
|
try:
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise MixedRouteLidarPackError("mixed-route review manifest is invalid") from exc
|
||||||
|
identity = manifest.get("identity") if isinstance(manifest, dict) else None
|
||||||
|
timeline = manifest.get("timeline") if isinstance(manifest, dict) else None
|
||||||
|
frames = manifest.get("frames") if isinstance(manifest, dict) else None
|
||||||
|
if (
|
||||||
|
manifest.get("schema_version") != REVIEW_SCHEMA
|
||||||
|
or not isinstance(identity, dict)
|
||||||
|
or identity.get("schema_version") != REVIEW_SCHEMA
|
||||||
|
or identity.get("ground_truth") is not False
|
||||||
|
or not isinstance(timeline, dict)
|
||||||
|
or not isinstance(frames, list)
|
||||||
|
or manifest.get("frame_count") != len(frames)
|
||||||
|
or not frames
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route review contract changed")
|
||||||
|
timeline_path = resolved / str(timeline.get("path"))
|
||||||
|
if (
|
||||||
|
not timeline_path.is_file()
|
||||||
|
or timeline.get("sha256") != _sha256(timeline_path)
|
||||||
|
or timeline.get("byte_length") != timeline_path.stat().st_size
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route review timeline changed")
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
previous_seconds = -1.0
|
||||||
|
with timeline_path.open(encoding="utf-8") as stream:
|
||||||
|
for expected, line in enumerate(stream):
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise MixedRouteLidarPackError("mixed-route timeline JSON is invalid") from exc
|
||||||
|
seconds = row.get("session_seconds") if isinstance(row, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(row, dict)
|
||||||
|
or row.get("frame_index") != expected
|
||||||
|
or row.get("sequence") != expected + 1
|
||||||
|
or row.get("source_sequence") != row.get("source_frame_index") + 1
|
||||||
|
or not isinstance(seconds, (int, float))
|
||||||
|
or isinstance(seconds, bool)
|
||||||
|
or float(seconds) <= previous_seconds
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route timeline row changed")
|
||||||
|
rows.append(row)
|
||||||
|
previous_seconds = float(seconds)
|
||||||
|
if len(rows) != len(frames):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route timeline is incomplete")
|
||||||
|
for frame in frames:
|
||||||
|
path = resolved / str(frame.get("path"))
|
||||||
|
if (
|
||||||
|
not path.is_file()
|
||||||
|
or frame.get("byte_length") != path.stat().st_size
|
||||||
|
or frame.get("sha256") != _sha256(path)
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("mixed-route source frame changed")
|
||||||
|
return manifest, rows
|
||||||
|
|
||||||
|
|
||||||
|
def _causal_history_clouds(
|
||||||
|
raw_path: Path,
|
||||||
|
*,
|
||||||
|
origin_monotonic_ns: int,
|
||||||
|
sample_seconds: list[float],
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
grouped: list[list[np.ndarray]] = [[] for _ in sample_seconds]
|
||||||
|
last = sample_seconds[-1]
|
||||||
|
for message in iter_replay_messages(raw_path):
|
||||||
|
monotonic_ns = message.received_monotonic_ns
|
||||||
|
if not isinstance(monotonic_ns, int) or monotonic_ns < origin_monotonic_ns:
|
||||||
|
raise MixedRouteLidarPackError("MQTT replay message has no compatible clock")
|
||||||
|
seconds = (monotonic_ns - origin_monotonic_ns) / 1e9
|
||||||
|
if seconds > last:
|
||||||
|
break
|
||||||
|
if not message.topic.endswith("/lio_pcl"):
|
||||||
|
continue
|
||||||
|
matching = [
|
||||||
|
index
|
||||||
|
for index, sample_time in enumerate(sample_seconds)
|
||||||
|
if sample_time - CAUSAL_HISTORY_SECONDS <= seconds <= sample_time
|
||||||
|
]
|
||||||
|
if not matching:
|
||||||
|
continue
|
||||||
|
frame = decode_lio_pcl(message.payload)
|
||||||
|
cloud = np.asarray(
|
||||||
|
[point.scaled_xyz(frame.header.scaler) for point in frame.points],
|
||||||
|
dtype=np.float32,
|
||||||
|
).reshape((-1, 3))
|
||||||
|
if cloud.shape[0] == 0 or not np.isfinite(cloud).all():
|
||||||
|
raise MixedRouteLidarPackError("causal LiDAR history is empty or non-finite")
|
||||||
|
for index in matching:
|
||||||
|
grouped[index].append(cloud)
|
||||||
|
result: list[np.ndarray] = []
|
||||||
|
for clouds in grouped:
|
||||||
|
if not clouds:
|
||||||
|
raise MixedRouteLidarPackError("selected frame has no causal LiDAR history")
|
||||||
|
result.append(np.concatenate(clouds))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
*,
|
||||||
|
job_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
review_pack_root: Path,
|
||||||
|
calibration_root: Path,
|
||||||
|
output_root: Path,
|
||||||
|
) -> Path:
|
||||||
|
job = validate_camera_compute_job(job_root)
|
||||||
|
session = session_root.resolve(strict=True)
|
||||||
|
if not session.is_dir() or session.name != job.session_id:
|
||||||
|
raise MixedRouteLidarPackError("camera job and observation session differ")
|
||||||
|
review, timeline = _read_review_pack(review_pack_root)
|
||||||
|
review_identity = review["identity"]
|
||||||
|
if (
|
||||||
|
review_identity.get("job_id") != job.job_id
|
||||||
|
or review_identity.get("input_sha256") != job.input_sha256
|
||||||
|
or review_identity.get("session_id") != job.session_id
|
||||||
|
or review_identity.get("source_id") != job.source_id
|
||||||
|
or review_identity.get("codec_epoch") != job.codec_epoch
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("review pack and camera job differ")
|
||||||
|
|
||||||
|
calibration, calibration_sha256 = _load_calibration_snapshot(
|
||||||
|
calibration_root.resolve(strict=True)
|
||||||
|
)
|
||||||
|
projection = Kb4ProjectionProfile.from_factory_calibration(calibration, job.source_id)
|
||||||
|
capture_root = session / "captures" / "mqtt_live"
|
||||||
|
origin_path = capture_root / "mqtt.timeline.origin.json"
|
||||||
|
origin = read_capture_clock_origin(origin_path)
|
||||||
|
anchors = [
|
||||||
|
CameraAnchor(
|
||||||
|
frame_index=int(row["frame_index"]),
|
||||||
|
source_frame_index=int(row["source_frame_index"]),
|
||||||
|
host_session_seconds=(
|
||||||
|
int(row["host_monotonic_ns"]) - origin.started_monotonic_ns
|
||||||
|
)
|
||||||
|
/ 1e9,
|
||||||
|
video_session_seconds=float(row["session_seconds"]),
|
||||||
|
)
|
||||||
|
for row in timeline
|
||||||
|
]
|
||||||
|
if any(
|
||||||
|
anchor.host_session_seconds != anchor.video_session_seconds
|
||||||
|
for anchor in anchors
|
||||||
|
):
|
||||||
|
raise MixedRouteLidarPackError("review timeline does not use host arrival time")
|
||||||
|
samples = list(
|
||||||
|
_lidar_samples(
|
||||||
|
capture_root / "mqtt.raw.k1mqtt",
|
||||||
|
anchors,
|
||||||
|
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||||
|
maximum_lidar_camera_delta_s=MAXIMUM_LIDAR_CAMERA_DELTA_MS / 1000.0,
|
||||||
|
maximum_pose_point_delta_s=MAXIMUM_POSE_POINT_DELTA_MS / 1000.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(samples) != len(anchors):
|
||||||
|
raise MixedRouteLidarPackError("LiDAR sampler did not account for every anchor")
|
||||||
|
|
||||||
|
count = len(anchors)
|
||||||
|
available = np.zeros((count,), dtype=np.bool_)
|
||||||
|
offsets = [0]
|
||||||
|
clouds: list[np.ndarray] = []
|
||||||
|
positions = np.full((count, 3), np.nan, dtype=np.float64)
|
||||||
|
quaternions = np.full((count, 4), np.nan, dtype=np.float64)
|
||||||
|
lidar_delta = np.full((count,), np.nan, dtype=np.float64)
|
||||||
|
pose_delta = np.full((count,), np.nan, dtype=np.float64)
|
||||||
|
sample_seconds: list[float] = []
|
||||||
|
for index, (anchor, sample) in enumerate(zip(anchors, samples, strict=True)):
|
||||||
|
if sample is None:
|
||||||
|
offsets.append(offsets[-1])
|
||||||
|
sample_seconds.append(float("nan"))
|
||||||
|
continue
|
||||||
|
cloud = np.asarray(
|
||||||
|
[
|
||||||
|
point.scaled_xyz(sample.point_frame.header.scaler)
|
||||||
|
for point in sample.point_frame.points
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
).reshape((-1, 3))
|
||||||
|
if cloud.shape[0] == 0 or not np.isfinite(cloud).all():
|
||||||
|
raise MixedRouteLidarPackError("selected LiDAR sample is empty or non-finite")
|
||||||
|
available[index] = True
|
||||||
|
clouds.append(cloud)
|
||||||
|
offsets.append(offsets[-1] + cloud.shape[0])
|
||||||
|
positions[index] = sample.pose_frame.position_xyz
|
||||||
|
quaternions[index] = sample.pose_frame.orientation_xyzw
|
||||||
|
lidar_delta[index] = (
|
||||||
|
sample.point_session_seconds - anchor.host_session_seconds
|
||||||
|
) * 1000.0
|
||||||
|
pose_delta[index] = (
|
||||||
|
sample.pose_session_seconds - sample.point_session_seconds
|
||||||
|
) * 1000.0
|
||||||
|
sample_seconds.append(sample.point_session_seconds)
|
||||||
|
|
||||||
|
if not available.all() or not np.isfinite(np.asarray(sample_seconds)).all():
|
||||||
|
raise MixedRouteLidarPackError(
|
||||||
|
"every mixed-route review island must have a temporally admissible LiDAR sample"
|
||||||
|
)
|
||||||
|
history_clouds = _causal_history_clouds(
|
||||||
|
capture_root / "mqtt.raw.k1mqtt",
|
||||||
|
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||||
|
sample_seconds=sample_seconds,
|
||||||
|
)
|
||||||
|
history_offsets = [0]
|
||||||
|
for cloud in history_clouds:
|
||||||
|
history_offsets.append(history_offsets[-1] + cloud.shape[0])
|
||||||
|
|
||||||
|
identity = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"session_id": job.session_id,
|
||||||
|
"source_id": job.source_id,
|
||||||
|
"camera_slot": "camera_1",
|
||||||
|
"calibration_sha256": calibration_sha256,
|
||||||
|
"review_pack_id": review["pack_id"],
|
||||||
|
"review_pack_identity_sha256": review["identity_sha256"],
|
||||||
|
"selected_source_frame_indices": [
|
||||||
|
int(row["source_frame_index"]) for row in timeline
|
||||||
|
],
|
||||||
|
"frame_count": count,
|
||||||
|
"available_lidar_frames": int(available.sum()),
|
||||||
|
"point_count": int(offsets[-1]),
|
||||||
|
"causal_history_seconds": CAUSAL_HISTORY_SECONDS,
|
||||||
|
"causal_history_point_count": int(history_offsets[-1]),
|
||||||
|
"temporal_policy": {
|
||||||
|
"binding": "nearest-host-arrival-best-effort",
|
||||||
|
"maximum_lidar_camera_delta_ms": MAXIMUM_LIDAR_CAMERA_DELTA_MS,
|
||||||
|
"maximum_pose_point_delta_ms": MAXIMUM_POSE_POINT_DELTA_MS,
|
||||||
|
"clock_source": "recorded-host-monotonic-arrival",
|
||||||
|
},
|
||||||
|
"projection": {
|
||||||
|
"model": "kb4",
|
||||||
|
"width": projection.width,
|
||||||
|
"height": projection.height,
|
||||||
|
"source_coordinates": "k1-map",
|
||||||
|
"target_camera": job.source_id,
|
||||||
|
},
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": {
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
pack_id = f"mixed-route-lidar-pack-{identity_sha256}"
|
||||||
|
parent = output_root.resolve()
|
||||||
|
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
final = parent / pack_id
|
||||||
|
if final.exists():
|
||||||
|
return final
|
||||||
|
staging = Path(tempfile.mkdtemp(prefix=f".{pack_id}.", dir=parent))
|
||||||
|
published = False
|
||||||
|
try:
|
||||||
|
arrays_path = staging / "lidar-pack.npz"
|
||||||
|
np.savez_compressed(
|
||||||
|
arrays_path,
|
||||||
|
frame_indices=np.arange(count, dtype=np.int64),
|
||||||
|
source_frame_indices=np.asarray(
|
||||||
|
[row["source_frame_index"] for row in timeline], dtype=np.int64
|
||||||
|
),
|
||||||
|
session_seconds=np.asarray(
|
||||||
|
[anchor.video_session_seconds for anchor in anchors], dtype=np.float64
|
||||||
|
),
|
||||||
|
host_session_seconds=np.asarray(
|
||||||
|
[anchor.host_session_seconds for anchor in anchors], dtype=np.float64
|
||||||
|
),
|
||||||
|
lidar_session_seconds=np.asarray(sample_seconds, dtype=np.float64),
|
||||||
|
sample_available=available,
|
||||||
|
cloud_offsets=np.asarray(offsets, dtype=np.int64),
|
||||||
|
cloud_points_map=(
|
||||||
|
np.concatenate(clouds) if clouds else np.empty((0, 3), dtype=np.float32)
|
||||||
|
),
|
||||||
|
pose_positions_map=positions,
|
||||||
|
pose_quaternions_map_from_lidar=quaternions,
|
||||||
|
lidar_camera_delta_ms=lidar_delta,
|
||||||
|
pose_point_delta_ms=pose_delta,
|
||||||
|
causal_history_seconds=np.asarray(
|
||||||
|
[CAUSAL_HISTORY_SECONDS], dtype=np.float64
|
||||||
|
),
|
||||||
|
causal_history_offsets=np.asarray(history_offsets, dtype=np.int64),
|
||||||
|
causal_history_points_map=np.concatenate(history_clouds),
|
||||||
|
intrinsic_fx_fy_cx_cy=np.asarray(
|
||||||
|
projection.intrinsic_fx_fy_cx_cy, dtype=np.float64
|
||||||
|
),
|
||||||
|
distortion_kb4=np.asarray(projection.distortion_kb4, dtype=np.float64),
|
||||||
|
t_camera_from_lidar=np.asarray(projection.t_camera_from_lidar, dtype=np.float64),
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"pack_id": pack_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"created_at_utc": datetime.now(UTC)
|
||||||
|
.isoformat(timespec="milliseconds")
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
"classification": "private-recorded-sensor-review-input",
|
||||||
|
"ground_truth": False,
|
||||||
|
"artifact": {
|
||||||
|
"path": arrays_path.name,
|
||||||
|
"media_type": "application/x-npz",
|
||||||
|
"byte_length": arrays_path.stat().st_size,
|
||||||
|
"sha256": _sha256(arrays_path),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(staging / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(staging, final)
|
||||||
|
published = True
|
||||||
|
finally:
|
||||||
|
if not published:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _arguments()
|
||||||
|
output = prepare(
|
||||||
|
job_root=args.job,
|
||||||
|
session_root=args.session,
|
||||||
|
review_pack_root=args.review_pack,
|
||||||
|
calibration_root=args.calibration,
|
||||||
|
output_root=args.output_root,
|
||||||
|
)
|
||||||
|
manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pack_id": manifest["pack_id"],
|
||||||
|
"output": str(output),
|
||||||
|
"frames": manifest["identity"]["frame_count"],
|
||||||
|
"lidar_frames": manifest["identity"]["available_lidar_frames"],
|
||||||
|
"points": manifest["identity"]["point_count"],
|
||||||
|
"artifact_sha256": manifest["artifact"]["sha256"],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -187,12 +187,15 @@ Write-Output "PHASE=e4-preflight-complete"
|
|||||||
$runToken = [Guid]::NewGuid().ToString("N")
|
$runToken = [Guid]::NewGuid().ToString("N")
|
||||||
$workRoot = Join-Path $tmpRoot ("{0}-e4-{1}" -f $job.job_id, $runToken)
|
$workRoot = Join-Path $tmpRoot ("{0}-e4-{1}" -f $job.job_id, $runToken)
|
||||||
$framesRoot = Join-Path $workRoot "frames"
|
$framesRoot = Join-Path $workRoot "frames"
|
||||||
|
$decodedFramesRoot = Join-Path $workRoot "decoded-by-pts"
|
||||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||||
$ptsPath = Join-Path $workRoot "pts.json"
|
$packetsPath = Join-Path $workRoot "packets.csv"
|
||||||
|
$decodeRepairPath = Join-Path $workRoot "decode-repair.json"
|
||||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||||
$publishRoot = Join-Path $derivedRoot (".{0}-e4-{1}.publish" -f $job.job_id, $runToken)
|
$publishRoot = Join-Path $derivedRoot (".{0}-e4-{1}.publish" -f $job.job_id, $runToken)
|
||||||
$stagingRoot = Join-Path $publishRoot "output"
|
$stagingRoot = Join-Path $publishRoot "output"
|
||||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||||
|
$null = New-Item -ItemType Directory -Path $decodedFramesRoot
|
||||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
$completed = $false
|
$completed = $false
|
||||||
@@ -230,29 +233,69 @@ try {
|
|||||||
|
|
||||||
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||||
Write-Output "PHASE=e4-frame-extraction-start"
|
Write-Output "PHASE=e4-frame-extraction-start"
|
||||||
& ffmpeg -hide_banner -loglevel fatal -i $streamPath -map 0:v:0 -fps_mode passthrough -frames:v $activeFrameCount (Join-Path $framesRoot "frame-%06d.png")
|
& ffprobe -v error -select_streams v:0 -show_packets -show_entries packet=pts,flags -of csv=p=0 -o $packetsPath $streamPath
|
||||||
|
Assert-LastExitCode "LAB E4 packet timestamp probe"
|
||||||
|
$packetRows = @(Get-Content -LiteralPath $packetsPath | Select-Object -First $activeFrameCount)
|
||||||
|
if ($packetRows.Count -ne $activeFrameCount) {
|
||||||
|
throw "LAB E4 packet count differs from the requested camera epoch"
|
||||||
|
}
|
||||||
|
|
||||||
|
& ffmpeg -hide_banner -loglevel error `
|
||||||
|
-hwaccel cuda -hwaccel_output_format cuda -c:v h264_cuvid `
|
||||||
|
-err_detect ignore_err -flags +output_corrupt -copyts `
|
||||||
|
-i $streamPath -map 0:v:0 -vf "hwdownload,format=nv12" `
|
||||||
|
-fps_mode passthrough -enc_time_base demux -frames:v $activeFrameCount `
|
||||||
|
-frame_pts 1 (Join-Path $decodedFramesRoot "frame-%d.png")
|
||||||
Assert-LastExitCode "LAB E4 camera extraction"
|
Assert-LastExitCode "LAB E4 camera extraction"
|
||||||
& ffprobe -v error -select_streams v:0 -show_entries frame=best_effort_timestamp_time -of json $streamPath | Set-Content -LiteralPath $ptsPath -Encoding utf8
|
|
||||||
Assert-LastExitCode "LAB E4 camera timestamp probe"
|
$decodedCount = @(Get-ChildItem -LiteralPath $decodedFramesRoot -File -Filter "frame-*.png").Count
|
||||||
|
$repairs = @()
|
||||||
|
$packetPts = @()
|
||||||
|
for ($index = 0; $index -lt $activeFrameCount; $index++) {
|
||||||
|
$columns = ([string]$packetRows[$index]).Split(",")
|
||||||
|
if ($columns.Count -lt 2) {
|
||||||
|
throw "LAB E4 packet timestamp row is malformed"
|
||||||
|
}
|
||||||
|
$pts = [int64]::Parse($columns[0].Trim(), [Globalization.CultureInfo]::InvariantCulture)
|
||||||
|
$packetPts += $pts
|
||||||
|
$decodedPath = Join-Path $decodedFramesRoot ("frame-{0}.png" -f $pts)
|
||||||
|
$canonicalPath = Join-Path $framesRoot ("frame-{0:D6}.png" -f ($index + 1))
|
||||||
|
if (Test-Path -LiteralPath $decodedPath -PathType Leaf) {
|
||||||
|
Move-Item -LiteralPath $decodedPath -Destination $canonicalPath
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($index -eq 0 -or $repairs.Count -ge 1) {
|
||||||
|
throw "LAB E4 source contains more than one recoverable decoder gap"
|
||||||
|
}
|
||||||
|
$previousPath = Join-Path $framesRoot ("frame-{0:D6}.png" -f $index)
|
||||||
|
Copy-Item -LiteralPath $previousPath -Destination $canonicalPath
|
||||||
|
$repairs += [ordered]@{
|
||||||
|
sequence = $index + 1
|
||||||
|
packet_pts = $pts
|
||||||
|
method = "duplicate-previous-decoded-frame"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||||
$ptsDocument = Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json
|
if ($decodedFrames.Count -ne $activeFrameCount) {
|
||||||
$pts = @($ptsDocument.frames)
|
|
||||||
if ($decodedFrames.Count -ne $activeFrameCount -or $pts.Count -lt $activeFrameCount) {
|
|
||||||
throw "Decoded LAB E4 frame count differs from the requested camera epoch"
|
throw "Decoded LAB E4 frame count differs from the requested camera epoch"
|
||||||
}
|
}
|
||||||
$firstEpochSeconds = [double]::Parse(
|
$decodeRepair = [ordered]@{
|
||||||
([string]$pts[0].best_effort_timestamp_time).Trim(),
|
schema_version = "missioncore.recorded-video-decode-repair/v1"
|
||||||
[Globalization.CultureInfo]::InvariantCulture
|
decoder = "ffmpeg-h264_cuvid-output-corrupt"
|
||||||
)
|
packets_requested = $activeFrameCount
|
||||||
|
frames_decoded = $decodedCount
|
||||||
|
repaired_frame_count = $repairs.Count
|
||||||
|
repairs = $repairs
|
||||||
|
}
|
||||||
|
$decodeRepair | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $decodeRepairPath -Encoding utf8
|
||||||
|
|
||||||
|
$firstPacketPts = [int64]$packetPts[0]
|
||||||
$previousEpochSeconds = -1.0
|
$previousEpochSeconds = -1.0
|
||||||
$timelineWriter = [IO.StreamWriter]::new($timelinePath, $false, [Text.UTF8Encoding]::new($false))
|
$timelineWriter = [IO.StreamWriter]::new($timelinePath, $false, [Text.UTF8Encoding]::new($false))
|
||||||
try {
|
try {
|
||||||
for ($index = 0; $index -lt $activeFrameCount; $index++) {
|
for ($index = 0; $index -lt $activeFrameCount; $index++) {
|
||||||
$epochSeconds = [double]::Parse(
|
$epochSeconds = ([int64]$packetPts[$index] - $firstPacketPts) / 90000.0
|
||||||
([string]$pts[$index].best_effort_timestamp_time).Trim(),
|
|
||||||
[Globalization.CultureInfo]::InvariantCulture
|
|
||||||
) - $firstEpochSeconds
|
|
||||||
if ($epochSeconds -le $previousEpochSeconds -or $epochSeconds -gt ($timelineDuration + 0.001)) {
|
if ($epochSeconds -le $previousEpochSeconds -or $epochSeconds -gt ($timelineDuration + 0.001)) {
|
||||||
throw "Decoded LAB E4 timestamps are not strictly monotonic inside the camera timeline"
|
throw "Decoded LAB E4 timestamps are not strictly monotonic inside the camera timeline"
|
||||||
}
|
}
|
||||||
@@ -313,6 +356,7 @@ try {
|
|||||||
Write-Output ("PHASE=e4-inference-start FRAMES={0}" -f $activeFrameCount)
|
Write-Output ("PHASE=e4-inference-start FRAMES={0}" -f $activeFrameCount)
|
||||||
& docker @runArgs
|
& docker @runArgs
|
||||||
Assert-LastExitCode "LAB E4 semantic inference"
|
Assert-LastExitCode "LAB E4 semantic inference"
|
||||||
|
Copy-Item -LiteralPath $decodeRepairPath -Destination (Join-Path $stagingRoot "decode-repair.json")
|
||||||
$freeBytesPostInference = Assert-FreeSpace "post-inference"
|
$freeBytesPostInference = Assert-FreeSpace "post-inference"
|
||||||
Write-Output "PHASE=e4-inference-complete"
|
Write-Output "PHASE=e4-inference-complete"
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,18 @@ param(
|
|||||||
|
|
||||||
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\experiments\lab-v1-vegetation",
|
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\experiments\lab-v1-vegetation",
|
||||||
|
|
||||||
[string]$RavnovesVideo = "D:\NDC_MISSIONCORE\runtime\experiments\e46e\inputs\right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8.mp4"
|
[string]$RavnovesVideo = "D:\NDC_MISSIONCORE\runtime\experiments\e46e\inputs\right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8.mp4",
|
||||||
|
|
||||||
|
[string]$RavnovesSourceId = "RAVNOVES00/right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8",
|
||||||
|
|
||||||
|
[string]$RavnovesSha256 = "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8",
|
||||||
|
|
||||||
|
[ValidateRange(1, 1000000)]
|
||||||
|
[int]$RavnovesExpectedFrameCount = 4489,
|
||||||
|
|
||||||
|
[string]$RavnovesBaseM4ResultId = "m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324",
|
||||||
|
|
||||||
|
[string]$RavnovesSourceProfile = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
@@ -38,6 +49,31 @@ $configRoot = Join-Path $ToolRoot "config"
|
|||||||
$benchmarkConfig = Join-Path $configRoot "lab-v1-goose-vegetation-benchmark-v1.json"
|
$benchmarkConfig = Join-Path $configRoot "lab-v1-goose-vegetation-benchmark-v1.json"
|
||||||
$policyConfig = Join-Path $configRoot "lab-v1-vegetation-mission-policy-v1.json"
|
$policyConfig = Join-Path $configRoot "lab-v1-vegetation-mission-policy-v1.json"
|
||||||
$providerMapConfig = Join-Path $configRoot "lab-v1-vegetation-provider-label-map-v1.json"
|
$providerMapConfig = Join-Path $configRoot "lab-v1-vegetation-provider-label-map-v1.json"
|
||||||
|
$ravnovesProfileDocument = $null
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($RavnovesSourceProfile)) {
|
||||||
|
$resolvedProfile = (Resolve-Path -LiteralPath $RavnovesSourceProfile).Path
|
||||||
|
if (-not $resolvedProfile.StartsWith($ToolRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "RAVNOVES source profile must stay under ToolRoot"
|
||||||
|
}
|
||||||
|
$ravnovesProfileDocument = Get-Content -LiteralPath $resolvedProfile -Raw | ConvertFrom-Json
|
||||||
|
$source = $ravnovesProfileDocument.source
|
||||||
|
if (
|
||||||
|
$ravnovesProfileDocument.schema_version -ne "missioncore.lab-v1-ravnoves-source/v1" -or
|
||||||
|
$null -eq $source -or
|
||||||
|
[string]::IsNullOrWhiteSpace([string]$source.source_id) -or
|
||||||
|
[string]$source.source_sha256 -notmatch "^[a-f0-9]{64}$" -or
|
||||||
|
[int]$source.expected_width -ne 800 -or
|
||||||
|
[int]$source.expected_height -ne 600 -or
|
||||||
|
[int]$source.expected_frame_count -lt 1 -or
|
||||||
|
[string]$source.crop_contract -ne "center-600-square-to-512; outside-crop-is-undefined"
|
||||||
|
) {
|
||||||
|
throw "RAVNOVES source profile is incompatible"
|
||||||
|
}
|
||||||
|
$RavnovesSourceId = [string]$source.source_id
|
||||||
|
$RavnovesSha256 = [string]$source.source_sha256
|
||||||
|
$RavnovesExpectedFrameCount = [int]$source.expected_frame_count
|
||||||
|
$RavnovesBaseM4ResultId = [string]$source.base_m4_result_id
|
||||||
|
}
|
||||||
$datasetRoot = Join-Path $AssetRoot "goose-2d\validation"
|
$datasetRoot = Join-Path $AssetRoot "goose-2d\validation"
|
||||||
$checkpointRelative = if ($candidateKey -eq "ddrnet") {
|
$checkpointRelative = if ($candidateKey -eq "ddrnet") {
|
||||||
"models\goose\ddrnet_class_512.pth"
|
"models\goose\ddrnet_class_512.pth"
|
||||||
@@ -51,7 +87,6 @@ $expectedCheckpointSha256 = if ($candidateKey -eq "ddrnet") {
|
|||||||
"6dd412c0c99115e359896c4cab43a8e6bce9e09b843e7fa885fe597b0a6121cd"
|
"6dd412c0c99115e359896c4cab43a8e6bce9e09b843e7fa885fe597b0a6121cd"
|
||||||
}
|
}
|
||||||
$expectedCheckpointBytes = if ($candidateKey -eq "ddrnet") { 259419077 } else { 98208249 }
|
$expectedCheckpointBytes = if ($candidateKey -eq "ddrnet") { 259419077 } else { 98208249 }
|
||||||
$ravnovesSha256 = "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8"
|
|
||||||
$frameIndices = @(0, 253, 512, 768, 1024, 1536, 2048, 2560, 3072, 3584, 4096, 4488)
|
$frameIndices = @(0, 253, 512, 768, 1024, 1536, 2048, 2560, 3072, 3584, 4096, 4488)
|
||||||
$dockerConfig = "D:\NDC_MISSIONCORE\datasets\state\lab-v1-vegetation\docker-config"
|
$dockerConfig = "D:\NDC_MISSIONCORE\datasets\state\lab-v1-vegetation\docker-config"
|
||||||
|
|
||||||
@@ -110,6 +145,18 @@ function Invoke-IsolatedRun {
|
|||||||
[string]$FramesRoot = ""
|
[string]$FramesRoot = ""
|
||||||
)
|
)
|
||||||
$containerName = "ndc-lab-v1-goose-$candidateKey-$([Guid]::NewGuid().ToString('N').Substring(0, 10))"
|
$containerName = "ndc-lab-v1-goose-$candidateKey-$([Guid]::NewGuid().ToString('N').Substring(0, 10))"
|
||||||
|
$activeConfigRoot = $configRoot
|
||||||
|
if ($RunMode -eq "ravnoves-video" -and $null -ne $ravnovesProfileDocument) {
|
||||||
|
$activeConfigRoot = Join-Path $RunRoot "effective-config"
|
||||||
|
New-Item -ItemType Directory -Path $activeConfigRoot | Out-Null
|
||||||
|
Copy-Item -LiteralPath $policyConfig -Destination $activeConfigRoot
|
||||||
|
Copy-Item -LiteralPath $providerMapConfig -Destination $activeConfigRoot
|
||||||
|
$benchmark = Get-Content -LiteralPath $benchmarkConfig -Raw | ConvertFrom-Json
|
||||||
|
$benchmark.ravnoves = $ravnovesProfileDocument.source
|
||||||
|
$benchmark | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath (
|
||||||
|
Join-Path $activeConfigRoot "lab-v1-goose-vegetation-benchmark-v1.json"
|
||||||
|
) -Encoding utf8
|
||||||
|
}
|
||||||
$visualCount = if ($RunMode -eq "ravnoves-video") { 0 } else { 12 }
|
$visualCount = if ($RunMode -eq "ravnoves-video") { 0 } else { 12 }
|
||||||
$arguments = @(
|
$arguments = @(
|
||||||
"run", "--rm", "--name", $containerName,
|
"run", "--rm", "--name", $containerName,
|
||||||
@@ -125,7 +172,7 @@ function Invoke-IsolatedRun {
|
|||||||
"--env", "HOME=/tmp",
|
"--env", "HOME=/tmp",
|
||||||
"--mount", "type=bind,src=$datasetRoot,dst=/data/goose,readonly",
|
"--mount", "type=bind,src=$datasetRoot,dst=/data/goose,readonly",
|
||||||
"--mount", "type=bind,src=$checkpoint,dst=/models/candidate.pth,readonly",
|
"--mount", "type=bind,src=$checkpoint,dst=/models/candidate.pth,readonly",
|
||||||
"--mount", "type=bind,src=$configRoot,dst=/config,readonly",
|
"--mount", "type=bind,src=$activeConfigRoot,dst=/config,readonly",
|
||||||
"--mount", "type=bind,src=$RunRoot,dst=/output",
|
"--mount", "type=bind,src=$RunRoot,dst=/output",
|
||||||
$image,
|
$image,
|
||||||
"--mode", $RunMode,
|
"--mode", $RunMode,
|
||||||
@@ -147,15 +194,27 @@ function Invoke-IsolatedRun {
|
|||||||
$tail = @($arguments[$mountIndex..($arguments.Count - 1)])
|
$tail = @($arguments[$mountIndex..($arguments.Count - 1)])
|
||||||
$arguments = $head + @("--mount", "type=bind,src=$FramesRoot,dst=/input,readonly") + $tail
|
$arguments = $head + @("--mount", "type=bind,src=$FramesRoot,dst=/input,readonly") + $tail
|
||||||
}
|
}
|
||||||
& docker @arguments
|
$dockerExitCode = -1
|
||||||
if ($LASTEXITCODE -ne 0) {
|
$previousErrorActionPreference = $ErrorActionPreference
|
||||||
throw "LAB V1 container failed with exit code $LASTEXITCODE"
|
try {
|
||||||
|
# Windows PowerShell exposes native stderr as ErrorRecord objects. Model
|
||||||
|
# libraries legitimately emit warnings there, so merge the stream and
|
||||||
|
# fail only on the native process exit code.
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& docker @arguments 2>&1 | ForEach-Object { Write-Output $_ }
|
||||||
|
$dockerExitCode = $LASTEXITCODE
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$ErrorActionPreference = $previousErrorActionPreference
|
||||||
|
}
|
||||||
|
if ($dockerExitCode -ne 0) {
|
||||||
|
throw "LAB V1 container failed with exit code $dockerExitCode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Export-RavnovesFrames {
|
function Export-RavnovesFrames {
|
||||||
param([string]$Destination)
|
param([string]$Destination)
|
||||||
Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $ravnovesSha256
|
Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $RavnovesSha256
|
||||||
New-Item -ItemType Directory -Path $Destination | Out-Null
|
New-Item -ItemType Directory -Path $Destination | Out-Null
|
||||||
$expression = ($frameIndices | ForEach-Object { "eq(n\,$_ )" }) -join "+"
|
$expression = ($frameIndices | ForEach-Object { "eq(n\,$_ )" }) -join "+"
|
||||||
$temporaryPattern = Join-Path $Destination "selected-%03d.png"
|
$temporaryPattern = Join-Path $Destination "selected-%03d.png"
|
||||||
@@ -175,14 +234,68 @@ function Export-RavnovesFrames {
|
|||||||
|
|
||||||
function Export-RavnovesVideoFrames {
|
function Export-RavnovesVideoFrames {
|
||||||
param([string]$Destination)
|
param([string]$Destination)
|
||||||
Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $ravnovesSha256
|
Assert-FileIdentity -Path $RavnovesVideo -ExpectedBytes (Get-Item -LiteralPath $RavnovesVideo).Length -ExpectedSha256 $RavnovesSha256
|
||||||
New-Item -ItemType Directory -Path $Destination | Out-Null
|
New-Item -ItemType Directory -Path $Destination | Out-Null
|
||||||
& ffmpeg -hide_banner -loglevel error -i $RavnovesVideo -map 0:v:0 -fps_mode passthrough (Join-Path $Destination "frame-%06d.png")
|
$decodedRoot = "{0}-decoded-by-pts" -f $Destination
|
||||||
|
$packetsPath = "{0}-packets.csv" -f $Destination
|
||||||
|
New-Item -ItemType Directory -Path $decodedRoot | Out-Null
|
||||||
|
& ffprobe -v error -select_streams v:0 -show_packets -show_entries packet=pts,flags -of csv=p=0 -o $packetsPath $RavnovesVideo
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "RAVNOVES full-video packet probe failed"
|
||||||
|
}
|
||||||
|
$packetRows = @(Get-Content -LiteralPath $packetsPath | Select-Object -First $RavnovesExpectedFrameCount)
|
||||||
|
if ($packetRows.Count -ne $RavnovesExpectedFrameCount) {
|
||||||
|
throw "RAVNOVES full-video packet sequence changed"
|
||||||
|
}
|
||||||
|
& ffmpeg -hide_banner -loglevel error `
|
||||||
|
-hwaccel cuda -hwaccel_output_format cuda -c:v h264_cuvid `
|
||||||
|
-err_detect ignore_err -flags +output_corrupt -copyts `
|
||||||
|
-i $RavnovesVideo -map 0:v:0 -vf "hwdownload,format=nv12" `
|
||||||
|
-fps_mode passthrough -enc_time_base demux -frames:v $RavnovesExpectedFrameCount `
|
||||||
|
-frame_pts 1 (Join-Path $decodedRoot "frame-%d.png")
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "RAVNOVES full-video frame extraction failed"
|
throw "RAVNOVES full-video frame extraction failed"
|
||||||
}
|
}
|
||||||
|
$decodedCount = @(Get-ChildItem -LiteralPath $decodedRoot -File -Filter "frame-*.png").Count
|
||||||
|
$repairs = @()
|
||||||
|
for ($index = 0; $index -lt $RavnovesExpectedFrameCount; $index++) {
|
||||||
|
$columns = ([string]$packetRows[$index]).Split(",")
|
||||||
|
if ($columns.Count -lt 2) {
|
||||||
|
throw "RAVNOVES full-video packet row is malformed"
|
||||||
|
}
|
||||||
|
$pts = [int64]::Parse($columns[0].Trim(), [Globalization.CultureInfo]::InvariantCulture)
|
||||||
|
$decodedPath = Join-Path $decodedRoot ("frame-{0}.png" -f $pts)
|
||||||
|
$canonicalPath = Join-Path $Destination ("frame-{0:D6}.png" -f ($index + 1))
|
||||||
|
if (Test-Path -LiteralPath $decodedPath -PathType Leaf) {
|
||||||
|
Move-Item -LiteralPath $decodedPath -Destination $canonicalPath
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ($index -eq 0 -or $repairs.Count -ge 1) {
|
||||||
|
throw "RAVNOVES source contains more than one recoverable decoder gap"
|
||||||
|
}
|
||||||
|
$previousPath = Join-Path $Destination ("frame-{0:D6}.png" -f $index)
|
||||||
|
Copy-Item -LiteralPath $previousPath -Destination $canonicalPath
|
||||||
|
$repairs += [ordered]@{
|
||||||
|
sequence = $index + 1
|
||||||
|
packet_pts = $pts
|
||||||
|
method = "duplicate-previous-decoded-frame"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Remove-Item -LiteralPath $decodedRoot -Recurse -Force
|
||||||
|
Remove-Item -LiteralPath $packetsPath -Force
|
||||||
|
[ordered]@{
|
||||||
|
schema_version = "missioncore.recorded-video-decode-repair/v1"
|
||||||
|
decoder = "ffmpeg-h264_cuvid-output-corrupt"
|
||||||
|
packets_requested = $RavnovesExpectedFrameCount
|
||||||
|
frames_decoded = $decodedCount
|
||||||
|
repaired_frame_count = $repairs.Count
|
||||||
|
repairs = $repairs
|
||||||
|
} | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (
|
||||||
|
Join-Path (Split-Path $Destination -Parent) "decode-repair.json"
|
||||||
|
) -Encoding utf8
|
||||||
$frames = @(Get-ChildItem -LiteralPath $Destination -File -Filter "frame-*.png" | Sort-Object Name)
|
$frames = @(Get-ChildItem -LiteralPath $Destination -File -Filter "frame-*.png" | Sort-Object Name)
|
||||||
if ($frames.Count -ne 4489 -or $frames[0].Name -ne "frame-000001.png" -or $frames[-1].Name -ne "frame-004489.png") {
|
$lastFrameName = "frame-{0:D6}.png" -f $RavnovesExpectedFrameCount
|
||||||
|
if ($frames.Count -ne $RavnovesExpectedFrameCount -or $frames[0].Name -ne "frame-000001.png" -or $frames[-1].Name -ne $lastFrameName) {
|
||||||
throw "RAVNOVES full-video frame sequence changed"
|
throw "RAVNOVES full-video frame sequence changed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,6 +357,9 @@ try {
|
|||||||
$framesRoot = Join-Path $runRoot "input-frames"
|
$framesRoot = Join-Path $runRoot "input-frames"
|
||||||
Export-RavnovesVideoFrames -Destination $framesRoot
|
Export-RavnovesVideoFrames -Destination $framesRoot
|
||||||
Invoke-IsolatedRun -RunMode "ravnoves-video" -RunRoot $runRoot -Limit 0 -FramesRoot $framesRoot
|
Invoke-IsolatedRun -RunMode "ravnoves-video" -RunRoot $runRoot -Limit 0 -FramesRoot $framesRoot
|
||||||
|
Copy-Item -LiteralPath (Join-Path $runRoot "decode-repair.json") -Destination (
|
||||||
|
Join-Path $runRoot "result\decode-repair.json"
|
||||||
|
)
|
||||||
Remove-Item -LiteralPath $framesRoot -Recurse -Force
|
Remove-Item -LiteralPath $framesRoot -Recurse -Force
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+340
@@ -0,0 +1,340 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run DDRNet on an immutable mixed-route camera review pack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import platform
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
from run_goose_vegetation_benchmark import (
|
||||||
|
CLASS_COUNT,
|
||||||
|
expand_mask,
|
||||||
|
infer,
|
||||||
|
load_mapping,
|
||||||
|
load_model,
|
||||||
|
percentile,
|
||||||
|
preprocess,
|
||||||
|
read_json,
|
||||||
|
save_image,
|
||||||
|
sha256,
|
||||||
|
stable_digest,
|
||||||
|
validate_contracts,
|
||||||
|
)
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.mixed-route-ddrnet-islands/v1"
|
||||||
|
PACK_SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
AUTHORITY = {
|
||||||
|
"ground_truth": False,
|
||||||
|
"candidate_accepted": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"camera_semantics_can_clear_rigid_geometry": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MixedRouteDdrnetError(RuntimeError):
|
||||||
|
"""The route pack or DDRNet evidence changed or is incomplete."""
|
||||||
|
|
||||||
|
|
||||||
|
def arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--pack", type=Path, required=True)
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
parser.add_argument("--policy", type=Path, required=True)
|
||||||
|
parser.add_argument("--provider-map", type=Path, required=True)
|
||||||
|
parser.add_argument("--checkpoint", type=Path, required=True)
|
||||||
|
parser.add_argument("--dataset-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def object_value(value: object, label: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||||
|
raise MixedRouteDdrnetError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def load_pack(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
pack = root.resolve(strict=True)
|
||||||
|
if not pack.is_dir() or pack.is_symlink():
|
||||||
|
raise MixedRouteDdrnetError("mixed-route review pack is unavailable")
|
||||||
|
manifest_path = pack / "manifest.json"
|
||||||
|
manifest = object_value(
|
||||||
|
json.loads(manifest_path.read_text(encoding="utf-8")),
|
||||||
|
"mixed-route manifest",
|
||||||
|
)
|
||||||
|
identity = object_value(manifest.get("identity"), "mixed-route identity")
|
||||||
|
identity_sha256 = manifest.get("identity_sha256")
|
||||||
|
frames = manifest.get("frames")
|
||||||
|
frame_count = manifest.get("frame_count")
|
||||||
|
if (
|
||||||
|
manifest.get("schema_version") != PACK_SCHEMA
|
||||||
|
or identity.get("schema_version") != PACK_SCHEMA
|
||||||
|
or not isinstance(identity_sha256, str)
|
||||||
|
or hashlib.sha256(canonical_json(identity)).hexdigest() != identity_sha256
|
||||||
|
or manifest.get("pack_id") != f"mixed-route-review-pack-{identity_sha256}"
|
||||||
|
or identity.get("ground_truth") is not False
|
||||||
|
or object_value(identity.get("authority"), "mixed-route authority").get(
|
||||||
|
"navigation_or_safety_accepted"
|
||||||
|
)
|
||||||
|
is not False
|
||||||
|
or not isinstance(frame_count, int)
|
||||||
|
or isinstance(frame_count, bool)
|
||||||
|
or not 1 <= frame_count <= 64
|
||||||
|
or not isinstance(frames, list)
|
||||||
|
or len(frames) != frame_count
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route review pack identity changed")
|
||||||
|
timeline_descriptor = object_value(manifest.get("timeline"), "mixed-route timeline")
|
||||||
|
timeline_path = pack / "timeline.jsonl"
|
||||||
|
if (
|
||||||
|
timeline_descriptor.get("path") != timeline_path.name
|
||||||
|
or timeline_path.stat().st_size != timeline_descriptor.get("byte_length")
|
||||||
|
or sha256(timeline_path) != timeline_descriptor.get("sha256")
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route timeline proof changed")
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
with timeline_path.open(encoding="utf-8") as stream:
|
||||||
|
for expected, line in enumerate(stream):
|
||||||
|
row = object_value(json.loads(line), "mixed-route timeline row")
|
||||||
|
seconds = row.get("session_seconds")
|
||||||
|
if (
|
||||||
|
row.get("frame_index") != expected
|
||||||
|
or row.get("sequence") != expected + 1
|
||||||
|
or not isinstance(row.get("source_sequence"), int)
|
||||||
|
or row.get("source_frame_index") != row["source_sequence"] - 1
|
||||||
|
or not isinstance(seconds, (int, float))
|
||||||
|
or isinstance(seconds, bool)
|
||||||
|
or (rows and float(seconds) <= float(rows[-1]["session_seconds"]))
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route timeline order changed")
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) != frame_count:
|
||||||
|
raise MixedRouteDdrnetError("mixed-route timeline is incomplete")
|
||||||
|
for expected, (descriptor_raw, row) in enumerate(zip(frames, rows)): # noqa: B905
|
||||||
|
descriptor = object_value(descriptor_raw, "mixed-route frame descriptor")
|
||||||
|
relative = descriptor.get("path")
|
||||||
|
if relative != f"frames/frame-{expected + 1:06d}.png":
|
||||||
|
raise MixedRouteDdrnetError("mixed-route frame path changed")
|
||||||
|
pure = PurePosixPath(relative)
|
||||||
|
path = pack.joinpath(*pure.parts)
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or not path.resolve().is_relative_to(pack)
|
||||||
|
or path.stat().st_size != descriptor.get("byte_length")
|
||||||
|
or sha256(path) != descriptor.get("sha256")
|
||||||
|
or not isinstance(descriptor.get("source_segment_sha256"), str)
|
||||||
|
or row.get("source_sequence")
|
||||||
|
!= identity["selected_sequences"][expected]
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route frame proof changed")
|
||||||
|
return manifest, rows
|
||||||
|
|
||||||
|
|
||||||
|
def overlay(source: Image.Image, semantic: np.ndarray, palette: np.ndarray) -> Image.Image:
|
||||||
|
if semantic.shape != (600, 800):
|
||||||
|
raise MixedRouteDdrnetError("expanded semantic mask shape changed")
|
||||||
|
base = source.convert("RGBA")
|
||||||
|
colors = Image.fromarray(palette[semantic], mode="RGBA")
|
||||||
|
return Image.alpha_composite(base, colors)
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> int:
|
||||||
|
args = arguments()
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise MixedRouteDdrnetError("CUDA is required for DDRNet islands")
|
||||||
|
if args.output.exists():
|
||||||
|
raise MixedRouteDdrnetError("DDRNet islands output already exists")
|
||||||
|
manifest, timeline = load_pack(args.pack)
|
||||||
|
config = read_json(args.config, "benchmark config")
|
||||||
|
policy = read_json(args.policy, "mission policy")
|
||||||
|
provider_map = read_json(args.provider_map, "provider map")
|
||||||
|
candidate = validate_contracts(config, policy, provider_map, "ddrnet")
|
||||||
|
checkpoint = args.checkpoint.resolve(strict=True)
|
||||||
|
if (
|
||||||
|
checkpoint.is_symlink()
|
||||||
|
or checkpoint.stat().st_size != candidate["checkpoint_size_bytes"]
|
||||||
|
or sha256(checkpoint) != candidate["checkpoint_sha256"]
|
||||||
|
):
|
||||||
|
raise MixedRouteDdrnetError("DDRNet checkpoint identity changed")
|
||||||
|
dataset_root = args.dataset_root.resolve(strict=True)
|
||||||
|
mapping_path = dataset_root / config["dataset"]["mapping_relative_path"]
|
||||||
|
names, palette = load_mapping(mapping_path, config["dataset"]["mapping_sha256"])
|
||||||
|
|
||||||
|
args.output.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||||
|
mask_root = args.output / "semantic-masks"
|
||||||
|
overlay_root = args.output / "overlay-frames"
|
||||||
|
mask_root.mkdir(mode=0o700)
|
||||||
|
overlay_root.mkdir(mode=0o700)
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
model, model_name, architecture_failures = load_model("ddrnet", checkpoint)
|
||||||
|
first_path = args.pack / manifest["frames"][0]["path"]
|
||||||
|
with Image.open(first_path) as opened:
|
||||||
|
warm_source = opened.convert("RGB")
|
||||||
|
warm_tensor, _ = preprocess(warm_source)
|
||||||
|
warmup_ms = [infer(model, warm_tensor)[1] for _ in range(3)]
|
||||||
|
torch.cuda.reset_peak_memory_stats()
|
||||||
|
|
||||||
|
latencies_ms: list[float] = []
|
||||||
|
aggregate = np.zeros(CLASS_COUNT, dtype=np.int64)
|
||||||
|
frame_results: list[dict[str, Any]] = []
|
||||||
|
started = time.perf_counter()
|
||||||
|
for index, (descriptor, timeline_row) in enumerate(
|
||||||
|
zip(manifest["frames"], timeline) # noqa: B905 - Worker image uses Python 3.9.
|
||||||
|
):
|
||||||
|
source_path = args.pack / descriptor["path"]
|
||||||
|
with Image.open(source_path) as opened:
|
||||||
|
source = opened.convert("RGB")
|
||||||
|
if source.size != (800, 600):
|
||||||
|
raise MixedRouteDdrnetError("mixed-route source resolution changed")
|
||||||
|
tensor, crop_box = preprocess(source)
|
||||||
|
prediction, latency_ms = infer(model, tensor)
|
||||||
|
expanded = expand_mask(prediction, source.size, crop_box)
|
||||||
|
latencies_ms.append(latency_ms)
|
||||||
|
aggregate += np.bincount(expanded.reshape(-1), minlength=CLASS_COUNT)
|
||||||
|
mask_path = mask_root / f"frame-{index + 1:06d}.png"
|
||||||
|
overlay_path = overlay_root / f"frame-{index + 1:06d}.png"
|
||||||
|
mask_sha256 = save_image(mask_path, expanded, "L")
|
||||||
|
overlay_sha256 = save_image(overlay_path, overlay(source, expanded, palette))
|
||||||
|
present = np.flatnonzero(np.bincount(expanded.reshape(-1), minlength=CLASS_COUNT))
|
||||||
|
frame_results.append(
|
||||||
|
{
|
||||||
|
"frame_index": index,
|
||||||
|
"source_sequence": timeline_row["source_sequence"],
|
||||||
|
"source_frame_index": timeline_row["source_frame_index"],
|
||||||
|
"session_seconds": timeline_row["session_seconds"],
|
||||||
|
"latency_ms": round(latency_ms, 6),
|
||||||
|
"present_classes": [
|
||||||
|
{"class_id": int(class_id), "label": names[int(class_id)]}
|
||||||
|
for class_id in present
|
||||||
|
],
|
||||||
|
"mask": {
|
||||||
|
"path": mask_path.relative_to(args.output).as_posix(),
|
||||||
|
"byte_length": mask_path.stat().st_size,
|
||||||
|
"sha256": mask_sha256,
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"path": overlay_path.relative_to(args.output).as_posix(),
|
||||||
|
"byte_length": overlay_path.stat().st_size,
|
||||||
|
"sha256": overlay_sha256,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
wall_seconds = time.perf_counter() - started
|
||||||
|
if len(frame_results) != manifest["frame_count"]:
|
||||||
|
raise MixedRouteDdrnetError("DDRNet island accounting changed")
|
||||||
|
timing = {
|
||||||
|
"prewarm_inference_count": len(warmup_ms),
|
||||||
|
"prewarm_latency_ms_first": round(warmup_ms[0], 6),
|
||||||
|
"prewarm_latency_ms_last": round(warmup_ms[-1], 6),
|
||||||
|
"inference_wall_seconds": round(wall_seconds, 6),
|
||||||
|
"latency_ms_mean": round(statistics.fmean(latencies_ms), 6),
|
||||||
|
"latency_ms_p50": round(percentile(latencies_ms, 0.5), 6),
|
||||||
|
"latency_ms_p95": round(percentile(latencies_ms, 0.95), 6),
|
||||||
|
"throughput_fps_from_mean_inference": round(
|
||||||
|
1000.0 / statistics.fmean(latencies_ms), 6
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if any(not math.isfinite(float(value)) for value in timing.values()):
|
||||||
|
raise MixedRouteDdrnetError("DDRNet timing is non-finite")
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"status": "review-islands-ready-not-accepted",
|
||||||
|
"worker_id": "worker-006",
|
||||||
|
"source": {
|
||||||
|
"pack_id": manifest["pack_id"],
|
||||||
|
"pack_identity_sha256": manifest["identity_sha256"],
|
||||||
|
"job_id": manifest["identity"]["job_id"],
|
||||||
|
"input_sha256": manifest["identity"]["input_sha256"],
|
||||||
|
"session_id": manifest["identity"]["session_id"],
|
||||||
|
"source_id": manifest["identity"]["source_id"],
|
||||||
|
"frame_count": manifest["frame_count"],
|
||||||
|
"ground_truth_available": False,
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"candidate_key": "ddrnet",
|
||||||
|
"candidate_id": candidate["candidate_id"],
|
||||||
|
"loaded_model_name": model_name,
|
||||||
|
"architecture_probe_failures": architecture_failures,
|
||||||
|
"checkpoint_size_bytes": checkpoint.stat().st_size,
|
||||||
|
"checkpoint_sha256": sha256(checkpoint),
|
||||||
|
},
|
||||||
|
"taxonomy": {
|
||||||
|
"schema_version": "missioncore.lab-v1-vegetation-taxonomy/v1",
|
||||||
|
"classes": [
|
||||||
|
{
|
||||||
|
"class_id": class_id,
|
||||||
|
"label": names[class_id],
|
||||||
|
"color_rgb": palette[class_id, :3].astype(int).tolist(),
|
||||||
|
"disposition": "undefined" if class_id == 0 else "prediction",
|
||||||
|
}
|
||||||
|
for class_id in range(CLASS_COUNT)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"aggregate_prediction_pixels": aggregate.tolist(),
|
||||||
|
"frames": frame_results,
|
||||||
|
"timing": timing,
|
||||||
|
"resource": {
|
||||||
|
"hostname": platform.node(),
|
||||||
|
"gpu_name": torch.cuda.get_device_name(0),
|
||||||
|
"peak_allocated_vram_bytes": int(torch.cuda.max_memory_allocated()),
|
||||||
|
"peak_reserved_vram_bytes": int(torch.cuda.max_memory_reserved()),
|
||||||
|
"torch_version": torch.__version__,
|
||||||
|
"cuda_runtime_version": torch.version.cuda,
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"pack_manifest_sha256": sha256(args.pack / "manifest.json"),
|
||||||
|
"config_sha256": sha256(args.config),
|
||||||
|
"policy_sha256": sha256(args.policy),
|
||||||
|
"provider_map_sha256": sha256(args.provider_map),
|
||||||
|
"runner_sha256": sha256(Path(__file__)),
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Selected independently decodable islands are not a complete route timeline.",
|
||||||
|
"RAVNOVES004TREE has no route truth; class colors are model predictions.",
|
||||||
|
"DDRNet evidence cannot clear rigid geometry, person or vehicle vetoes.",
|
||||||
|
],
|
||||||
|
"authority": AUTHORITY,
|
||||||
|
}
|
||||||
|
result["result_id"] = f"mixed-route-ddrnet-islands-{stable_digest(result)}"
|
||||||
|
(args.output / "result.json").write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"result_id": result["result_id"],
|
||||||
|
"frames": len(frame_results),
|
||||||
|
"latency_p95_ms": timing["latency_ms_p95"],
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(run())
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seal fail-closed TRAVEL/TGS evidence for mixed-route review islands."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from build_tgs_fail_closed_evidence import (
|
||||||
|
TgsEvidenceError,
|
||||||
|
_load_float32,
|
||||||
|
classify_exact_input,
|
||||||
|
costmap_grid,
|
||||||
|
rasterize_costmap,
|
||||||
|
sha256_file,
|
||||||
|
write_deterministic_npz,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = "missioncore.mixed-route-tgs-review-profile/v1"
|
||||||
|
INPUT_SCHEMA = "missioncore.mixed-route-tgs-input/v1"
|
||||||
|
RESULT_SCHEMA = "missioncore.mixed-route-tgs-result/v1"
|
||||||
|
FRAME_COUNT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _timing(path: Path) -> dict[str, object]:
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
with path.open(encoding="utf-8", newline="") as stream:
|
||||||
|
for raw in csv.DictReader(stream, delimiter="\t"):
|
||||||
|
try:
|
||||||
|
row = {
|
||||||
|
"profile_id": str(raw["profile"]),
|
||||||
|
"slot": int(raw["slot"]),
|
||||||
|
"wall_seconds": float(raw["wall_seconds"]),
|
||||||
|
"max_rss_kib": int(raw["max_rss_kib"]),
|
||||||
|
}
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise TgsEvidenceError("TGS timing row is invalid") from exc
|
||||||
|
if (
|
||||||
|
row["profile_id"] not in {"current_increment", "causal_rolling_1s"}
|
||||||
|
or not 0 <= row["slot"] < FRAME_COUNT
|
||||||
|
or not 0 <= row["wall_seconds"] < 60
|
||||||
|
or not 0 < row["max_rss_kib"] < 16 * 1024 * 1024
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("TGS timing value is invalid")
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) != FRAME_COUNT * 2:
|
||||||
|
raise TgsEvidenceError("TGS timing is incomplete")
|
||||||
|
seconds = np.asarray([row["wall_seconds"] for row in rows], dtype=np.float64)
|
||||||
|
return {
|
||||||
|
"runs": rows,
|
||||||
|
"wall_seconds_mean": round(float(seconds.mean()), 6),
|
||||||
|
"wall_seconds_p95": round(float(np.percentile(seconds, 95)), 6),
|
||||||
|
"max_rss_kib": max(int(row["max_rss_kib"]) for row in rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build(run_root: Path, config_path: Path, output_root: Path) -> dict[str, object]:
|
||||||
|
if output_root.exists():
|
||||||
|
raise TgsEvidenceError("mixed-route TGS evidence already exists")
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
source = config.get("source") if isinstance(config, dict) else None
|
||||||
|
invariants = config.get("invariants") if isinstance(config, dict) else None
|
||||||
|
if (
|
||||||
|
config.get("schema_version") != CONFIG_SCHEMA
|
||||||
|
or not isinstance(source, dict)
|
||||||
|
or not isinstance(invariants, dict)
|
||||||
|
or invariants.get("aos_allowed") is not False
|
||||||
|
or invariants.get("missing_support_means_free") is not False
|
||||||
|
or invariants.get("future_frames_used") is not False
|
||||||
|
or invariants.get("navigation_or_actuation_allowed") is not False
|
||||||
|
or config.get("state_codes")
|
||||||
|
!= {
|
||||||
|
"UNOBSERVED": 0,
|
||||||
|
"GROUND_SUPPORT": 1,
|
||||||
|
"NONGROUND_OCCUPIED": 2,
|
||||||
|
"UNKNOWN_REJECTED": 3,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("mixed-route TGS profile changed")
|
||||||
|
input_manifest_path = run_root / "inputs" / "input-manifest.json"
|
||||||
|
input_manifest = json.loads(input_manifest_path.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
input_manifest.get("schema_version") != INPUT_SCHEMA
|
||||||
|
or input_manifest.get("source_pack_id") != source.get("source_pack_id")
|
||||||
|
or input_manifest.get("source_pack_sha256")
|
||||||
|
!= source.get("source_pack_sha256")
|
||||||
|
or input_manifest.get("config_sha256") != sha256_file(config_path)
|
||||||
|
or input_manifest.get("coordinate_frame") != "map-gravity-local"
|
||||||
|
or input_manifest.get("future_frames_used") is not False
|
||||||
|
or input_manifest.get("frame_count") != FRAME_COUNT
|
||||||
|
or len(input_manifest.get("records", [])) != FRAME_COUNT * 2
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("mixed-route TGS input manifest changed")
|
||||||
|
records = {
|
||||||
|
(str(row["profile_id"]), int(row["slot"])): row
|
||||||
|
for row in input_manifest["records"]
|
||||||
|
}
|
||||||
|
if len(records) != FRAME_COUNT * 2:
|
||||||
|
raise TgsEvidenceError("mixed-route TGS input records are not unique")
|
||||||
|
|
||||||
|
cell_size = float(config["costmap"]["cell_size_m"])
|
||||||
|
radius = float(config["costmap"]["radius_m"])
|
||||||
|
grid = costmap_grid(radius, cell_size)
|
||||||
|
arrays: dict[str, np.ndarray] = {
|
||||||
|
"costmap_cell_indices_xy": grid[:, :2].astype(np.int32),
|
||||||
|
"costmap_cell_centers_xy_m": grid[:, 2:].astype(np.float32),
|
||||||
|
"source_frame_indices": np.asarray(
|
||||||
|
[
|
||||||
|
records[("current_increment", slot)]["source_frame_index"]
|
||||||
|
for slot in range(FRAME_COUNT)
|
||||||
|
],
|
||||||
|
dtype=np.int64,
|
||||||
|
),
|
||||||
|
"session_seconds": np.asarray(
|
||||||
|
[
|
||||||
|
records[("current_increment", slot)]["session_seconds"]
|
||||||
|
for slot in range(FRAME_COUNT)
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
summaries: list[dict[str, object]] = []
|
||||||
|
for profile_id in ("current_increment", "causal_rolling_1s"):
|
||||||
|
all_points: list[np.ndarray] = []
|
||||||
|
all_states: list[np.ndarray] = []
|
||||||
|
offsets = [0]
|
||||||
|
grid_states: list[np.ndarray] = []
|
||||||
|
ground_counts: list[np.ndarray] = []
|
||||||
|
nonground_counts: list[np.ndarray] = []
|
||||||
|
rejected_counts: list[np.ndarray] = []
|
||||||
|
z_bounds_rows: list[np.ndarray] = []
|
||||||
|
for slot in range(FRAME_COUNT):
|
||||||
|
record = records[(profile_id, slot)]
|
||||||
|
native_path = run_root / "inputs" / str(record["relative_path"])
|
||||||
|
if (
|
||||||
|
not native_path.is_file()
|
||||||
|
or native_path.stat().st_size != record["bytes"]
|
||||||
|
or sha256_file(native_path) != record["sha256"]
|
||||||
|
):
|
||||||
|
raise TgsEvidenceError("sealed mixed-route TGS input changed")
|
||||||
|
output = run_root / "outputs" / profile_id
|
||||||
|
points, states = classify_exact_input(
|
||||||
|
_load_float32(native_path, 4),
|
||||||
|
_load_float32(output / f"{slot}_ground.bin", 4),
|
||||||
|
_load_float32(output / f"{slot}_nonground.bin", 4),
|
||||||
|
min_range_m=float(config["tgs"]["min_range_m"]),
|
||||||
|
max_range_m=float(config["tgs"]["max_range_m"]),
|
||||||
|
)
|
||||||
|
grid_state, ground, nonground, rejected, z_bounds = rasterize_costmap(
|
||||||
|
points,
|
||||||
|
states,
|
||||||
|
grid,
|
||||||
|
cell_size_m=cell_size,
|
||||||
|
)
|
||||||
|
all_points.append(points.astype(np.float32, copy=False))
|
||||||
|
all_states.append(states)
|
||||||
|
offsets.append(offsets[-1] + points.shape[0])
|
||||||
|
grid_states.append(grid_state)
|
||||||
|
ground_counts.append(ground)
|
||||||
|
nonground_counts.append(nonground)
|
||||||
|
rejected_counts.append(rejected)
|
||||||
|
z_bounds_rows.append(z_bounds)
|
||||||
|
accounted = (
|
||||||
|
np.count_nonzero(states == 1)
|
||||||
|
+ np.count_nonzero(states == 2)
|
||||||
|
+ np.count_nonzero(states == 3)
|
||||||
|
== points.shape[0]
|
||||||
|
)
|
||||||
|
summaries.append(
|
||||||
|
{
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"slot": slot,
|
||||||
|
"frame_index": int(record["frame_index"]),
|
||||||
|
"source_frame_index": int(record["source_frame_index"]),
|
||||||
|
"source_sequence": int(record["source_sequence"]),
|
||||||
|
"session_seconds": float(record["session_seconds"]),
|
||||||
|
"point_count": int(points.shape[0]),
|
||||||
|
"ground_point_count": int(np.count_nonzero(states == 1)),
|
||||||
|
"nonground_point_count": int(np.count_nonzero(states == 2)),
|
||||||
|
"rejected_point_count": int(np.count_nonzero(states == 3)),
|
||||||
|
"ground_cell_count": int(np.count_nonzero(grid_state == 1)),
|
||||||
|
"nonground_cell_count": int(np.count_nonzero(grid_state == 2)),
|
||||||
|
"rejected_cell_count": int(np.count_nonzero(grid_state == 3)),
|
||||||
|
"unobserved_cell_count": int(np.count_nonzero(grid_state == 0)),
|
||||||
|
"all_points_accounted": bool(accounted),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
arrays[f"{profile_id}_points_xyz_m"] = np.concatenate(all_points)
|
||||||
|
arrays[f"{profile_id}_point_states"] = np.concatenate(all_states)
|
||||||
|
arrays[f"{profile_id}_point_offsets"] = np.asarray(offsets, dtype=np.int64)
|
||||||
|
arrays[f"{profile_id}_costmap_states"] = np.stack(grid_states)
|
||||||
|
arrays[f"{profile_id}_costmap_ground_point_counts"] = np.stack(ground_counts)
|
||||||
|
arrays[f"{profile_id}_costmap_nonground_point_counts"] = np.stack(
|
||||||
|
nonground_counts
|
||||||
|
)
|
||||||
|
arrays[f"{profile_id}_costmap_rejected_point_counts"] = np.stack(
|
||||||
|
rejected_counts
|
||||||
|
)
|
||||||
|
arrays[f"{profile_id}_costmap_z_bounds_m"] = np.stack(z_bounds_rows)
|
||||||
|
if not all(bool(row["all_points_accounted"]) for row in summaries):
|
||||||
|
raise TgsEvidenceError("mixed-route TGS lost an eligible point")
|
||||||
|
|
||||||
|
output_root.mkdir(parents=True)
|
||||||
|
evidence_path = output_root / "evidence.npz"
|
||||||
|
write_deterministic_npz(evidence_path, arrays)
|
||||||
|
timing = _timing(run_root / "tgs-timing.tsv")
|
||||||
|
result = {
|
||||||
|
"schema_version": RESULT_SCHEMA,
|
||||||
|
"status": "passed-review-only",
|
||||||
|
"source": {
|
||||||
|
"source_id": source["source_id"],
|
||||||
|
"session_id": source["session_id"],
|
||||||
|
"review_pack_id": source["review_pack_id"],
|
||||||
|
"source_pack_id": source["source_pack_id"],
|
||||||
|
"source_pack_sha256": source["source_pack_sha256"],
|
||||||
|
},
|
||||||
|
"config_sha256": sha256_file(config_path),
|
||||||
|
"input_manifest_sha256": sha256_file(input_manifest_path),
|
||||||
|
"evidence": {
|
||||||
|
"path": "evidence.npz",
|
||||||
|
"bytes": evidence_path.stat().st_size,
|
||||||
|
"sha256": sha256_file(evidence_path),
|
||||||
|
},
|
||||||
|
"costmap": {
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"cell_size_m": cell_size,
|
||||||
|
"radius_m": radius,
|
||||||
|
"cell_count": int(grid.shape[0]),
|
||||||
|
},
|
||||||
|
"anchors": summaries,
|
||||||
|
"timing": timing,
|
||||||
|
"summary": {
|
||||||
|
"frame_count": FRAME_COUNT,
|
||||||
|
"anchor_profile_count": len(summaries),
|
||||||
|
"all_eligible_points_accounted": True,
|
||||||
|
"aos_used": False,
|
||||||
|
"primary_profile": "causal_rolling_1s",
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Selected review islands are not a complete route timeline.",
|
||||||
|
(
|
||||||
|
"TGS separates local ground support from non-ground evidence; it does not "
|
||||||
|
"prove ditch or negative-obstacle detection."
|
||||||
|
),
|
||||||
|
"Camera projection is visual evidence only and cannot clear rigid geometry.",
|
||||||
|
],
|
||||||
|
"authority": {
|
||||||
|
"visual_quality_accepted": False,
|
||||||
|
"traversability_accepted": False,
|
||||||
|
"realtime_accepted": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(output_root / "result.json").write_text(
|
||||||
|
json.dumps(result, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--run-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
result = build(args.run_root, args.config, args.output_root)
|
||||||
|
print(json.dumps(result["summary"], sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare exact mixed-route LiDAR islands for isolated TRAVEL/TGS review."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from prepare_tgs_fail_closed_inputs import TgsInputError, gravity_local_xyzi
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = "missioncore.mixed-route-tgs-review-profile/v1"
|
||||||
|
PACK_SCHEMA = "missioncore.mixed-route-lidar-pack/v1"
|
||||||
|
INPUT_SCHEMA = "missioncore.mixed-route-tgs-input/v1"
|
||||||
|
FRAME_COUNT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _slice(points: np.ndarray, offsets: np.ndarray, index: int) -> np.ndarray:
|
||||||
|
return points[int(offsets[index]) : int(offsets[index + 1])]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_offsets(offsets: np.ndarray, point_count: int) -> bool:
|
||||||
|
return bool(
|
||||||
|
offsets.shape == (FRAME_COUNT + 1,)
|
||||||
|
and offsets.dtype == np.int64
|
||||||
|
and int(offsets[0]) == 0
|
||||||
|
and int(offsets[-1]) == point_count
|
||||||
|
and np.all(np.diff(offsets) > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(source_root: Path, config_path: Path, output_root: Path) -> dict[str, object]:
|
||||||
|
if output_root.exists():
|
||||||
|
raise TgsInputError("mixed-route TGS output already exists")
|
||||||
|
source = source_root.resolve(strict=True)
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
manifest = json.loads((source / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
identity = manifest.get("identity") if isinstance(manifest, dict) else None
|
||||||
|
artifact = manifest.get("artifact") if isinstance(manifest, dict) else None
|
||||||
|
source_config = config.get("source") if isinstance(config, dict) else None
|
||||||
|
invariants = config.get("invariants") if isinstance(config, dict) else None
|
||||||
|
profiles = config.get("profiles") if isinstance(config, dict) else None
|
||||||
|
if (
|
||||||
|
config.get("schema_version") != CONFIG_SCHEMA
|
||||||
|
or not isinstance(source_config, dict)
|
||||||
|
or not isinstance(invariants, dict)
|
||||||
|
or not isinstance(profiles, dict)
|
||||||
|
or set(profiles) != {"current_increment", "causal_rolling_1s"}
|
||||||
|
or source_config.get("input_coordinate_frame")
|
||||||
|
!= "map-gravity-local-translation-only"
|
||||||
|
or invariants.get("lidar_orientation_applied_to_tgs_input") is not False
|
||||||
|
or invariants.get("future_frames_used") is not False
|
||||||
|
or invariants.get("navigation_or_actuation_allowed") is not False
|
||||||
|
or manifest.get("schema_version") != PACK_SCHEMA
|
||||||
|
or not isinstance(identity, dict)
|
||||||
|
or identity.get("schema_version") != PACK_SCHEMA
|
||||||
|
or identity.get("session_id") != source_config.get("session_id")
|
||||||
|
or identity.get("review_pack_id") != source_config.get("review_pack_id")
|
||||||
|
or manifest.get("pack_id") != source_config.get("source_pack_id")
|
||||||
|
or not isinstance(artifact, dict)
|
||||||
|
or artifact.get("path") != "lidar-pack.npz"
|
||||||
|
or artifact.get("sha256") != source_config.get("source_pack_sha256")
|
||||||
|
or identity.get("frame_count") != FRAME_COUNT
|
||||||
|
or identity.get("available_lidar_frames") != FRAME_COUNT
|
||||||
|
or identity.get("causal_history_seconds")
|
||||||
|
!= float(profiles["causal_rolling_1s"]["history_seconds"])
|
||||||
|
or identity.get("ground_truth") is not False
|
||||||
|
):
|
||||||
|
raise TgsInputError("mixed-route TGS source contract changed")
|
||||||
|
pack_path = source / "lidar-pack.npz"
|
||||||
|
if (
|
||||||
|
not pack_path.is_file()
|
||||||
|
or pack_path.stat().st_size != artifact.get("byte_length")
|
||||||
|
or sha256_file(pack_path) != artifact.get("sha256")
|
||||||
|
):
|
||||||
|
raise TgsInputError("mixed-route LiDAR pack changed")
|
||||||
|
|
||||||
|
required = {
|
||||||
|
"frame_indices",
|
||||||
|
"source_frame_indices",
|
||||||
|
"session_seconds",
|
||||||
|
"lidar_session_seconds",
|
||||||
|
"sample_available",
|
||||||
|
"cloud_offsets",
|
||||||
|
"cloud_points_map",
|
||||||
|
"pose_positions_map",
|
||||||
|
"lidar_camera_delta_ms",
|
||||||
|
"pose_point_delta_ms",
|
||||||
|
"causal_history_seconds",
|
||||||
|
"causal_history_offsets",
|
||||||
|
"causal_history_points_map",
|
||||||
|
}
|
||||||
|
with np.load(pack_path, allow_pickle=False) as archive:
|
||||||
|
if not required.issubset(archive.files):
|
||||||
|
raise TgsInputError("mixed-route LiDAR pack members changed")
|
||||||
|
arrays = {name: archive[name] for name in required}
|
||||||
|
current_points = arrays["cloud_points_map"]
|
||||||
|
history_points = arrays["causal_history_points_map"]
|
||||||
|
if (
|
||||||
|
arrays["frame_indices"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["frame_indices"].dtype != np.int64
|
||||||
|
or not np.array_equal(arrays["frame_indices"], np.arange(FRAME_COUNT))
|
||||||
|
or arrays["source_frame_indices"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["source_frame_indices"].dtype != np.int64
|
||||||
|
or np.any(np.diff(arrays["source_frame_indices"]) <= 0)
|
||||||
|
or arrays["session_seconds"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["session_seconds"].dtype != np.float64
|
||||||
|
or np.any(np.diff(arrays["session_seconds"]) <= 0)
|
||||||
|
or arrays["lidar_session_seconds"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["lidar_session_seconds"].dtype != np.float64
|
||||||
|
or arrays["sample_available"].shape != (FRAME_COUNT,)
|
||||||
|
or arrays["sample_available"].dtype != np.bool_
|
||||||
|
or not arrays["sample_available"].all()
|
||||||
|
or current_points.ndim != 2
|
||||||
|
or current_points.shape[1:] != (3,)
|
||||||
|
or current_points.dtype != np.float32
|
||||||
|
or history_points.ndim != 2
|
||||||
|
or history_points.shape[1:] != (3,)
|
||||||
|
or history_points.dtype != np.float32
|
||||||
|
or not np.isfinite(current_points).all()
|
||||||
|
or not np.isfinite(history_points).all()
|
||||||
|
or not _validate_offsets(arrays["cloud_offsets"], current_points.shape[0])
|
||||||
|
or not _validate_offsets(
|
||||||
|
arrays["causal_history_offsets"], history_points.shape[0]
|
||||||
|
)
|
||||||
|
or arrays["pose_positions_map"].shape != (FRAME_COUNT, 3)
|
||||||
|
or arrays["pose_positions_map"].dtype != np.float64
|
||||||
|
or not np.isfinite(arrays["pose_positions_map"]).all()
|
||||||
|
or arrays["causal_history_seconds"].shape != (1,)
|
||||||
|
or float(arrays["causal_history_seconds"][0])
|
||||||
|
!= float(profiles["causal_rolling_1s"]["history_seconds"])
|
||||||
|
or np.any(np.abs(arrays["lidar_camera_delta_ms"]) > 100.0)
|
||||||
|
or np.any(np.abs(arrays["pose_point_delta_ms"]) > 100.0)
|
||||||
|
):
|
||||||
|
raise TgsInputError("mixed-route LiDAR arrays changed")
|
||||||
|
|
||||||
|
records: list[dict[str, object]] = []
|
||||||
|
for profile_id in ("current_increment", "causal_rolling_1s"):
|
||||||
|
for slot in range(FRAME_COUNT):
|
||||||
|
if profile_id == "current_increment":
|
||||||
|
points_map = _slice(
|
||||||
|
current_points, arrays["cloud_offsets"], slot
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
points_map = _slice(
|
||||||
|
history_points, arrays["causal_history_offsets"], slot
|
||||||
|
)
|
||||||
|
radius = float(profiles[profile_id]["local_radius_m"])
|
||||||
|
relative_xy = (
|
||||||
|
points_map[:, :2].astype(np.float64)
|
||||||
|
- arrays["pose_positions_map"][slot, :2]
|
||||||
|
)
|
||||||
|
points_map = points_map[np.linalg.norm(relative_xy, axis=1) <= radius]
|
||||||
|
native = gravity_local_xyzi(
|
||||||
|
points_map, arrays["pose_positions_map"][slot]
|
||||||
|
)
|
||||||
|
if native.shape[0] == 0:
|
||||||
|
raise TgsInputError("mixed-route TGS profile produced an empty cloud")
|
||||||
|
target = (
|
||||||
|
output_root
|
||||||
|
/ "profiles"
|
||||||
|
/ profile_id
|
||||||
|
/ "velodyne"
|
||||||
|
/ f"{slot:06d}.bin"
|
||||||
|
)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(np.ascontiguousarray(native).tobytes())
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"slot": slot,
|
||||||
|
"frame_index": slot,
|
||||||
|
"source_frame_index": int(
|
||||||
|
arrays["source_frame_indices"][slot]
|
||||||
|
),
|
||||||
|
"source_sequence": int(
|
||||||
|
arrays["source_frame_indices"][slot]
|
||||||
|
)
|
||||||
|
+ 1,
|
||||||
|
"session_seconds": float(arrays["session_seconds"][slot]),
|
||||||
|
"lidar_session_seconds": float(
|
||||||
|
arrays["lidar_session_seconds"][slot]
|
||||||
|
),
|
||||||
|
"lidar_camera_delta_ms": float(
|
||||||
|
arrays["lidar_camera_delta_ms"][slot]
|
||||||
|
),
|
||||||
|
"pose_point_delta_ms": float(
|
||||||
|
arrays["pose_point_delta_ms"][slot]
|
||||||
|
),
|
||||||
|
"point_count": int(native.shape[0]),
|
||||||
|
"relative_path": target.relative_to(output_root).as_posix(),
|
||||||
|
"bytes": target.stat().st_size,
|
||||||
|
"sha256": sha256_file(target),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
manifest_out = {
|
||||||
|
"schema_version": INPUT_SCHEMA,
|
||||||
|
"source_pack_id": manifest["pack_id"],
|
||||||
|
"source_pack_sha256": artifact["sha256"],
|
||||||
|
"config_sha256": sha256_file(config_path),
|
||||||
|
"coordinate_frame": "map-gravity-local",
|
||||||
|
"transform": "translation-only-preserve-map-gravity-axis",
|
||||||
|
"intensity_policy": "zero-filled-algorithm-compatibility-only",
|
||||||
|
"future_frames_used": False,
|
||||||
|
"frame_count": FRAME_COUNT,
|
||||||
|
"profile_count": 2,
|
||||||
|
"records": records,
|
||||||
|
"authority": {
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
manifest_path = output_root / "input-manifest.json"
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(manifest_out, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return manifest_out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--source-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
manifest = prepare(args.source_root, args.config, args.output_root)
|
||||||
|
print(json.dumps({"ok": True, "records": len(manifest["records"])}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish exact, independently decodable camera islands for mixed-route review."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from k1link.compute.jobs import validate_camera_compute_job
|
||||||
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
MAX_INDEX_LINE_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class MixedRouteReviewPackError(RuntimeError):
|
||||||
|
"""The selected camera evidence cannot be published without ambiguity."""
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _sequences(value: str) -> tuple[int, ...]:
|
||||||
|
try:
|
||||||
|
sequences = tuple(int(item) for item in value.split(","))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError("sequences must be comma-separated integers") from exc
|
||||||
|
if not sequences or any(item < 1 for item in sequences):
|
||||||
|
raise argparse.ArgumentTypeError("sequences must be positive")
|
||||||
|
if len(set(sequences)) != len(sequences) or tuple(sorted(sequences)) != sequences:
|
||||||
|
raise argparse.ArgumentTypeError("sequences must be unique and increasing")
|
||||||
|
return sequences
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--job", type=Path, required=True)
|
||||||
|
parser.add_argument("--session", type=Path, required=True)
|
||||||
|
parser.add_argument("--sequences", type=_sequences, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--ffmpeg", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_selected_index(
|
||||||
|
path: Path,
|
||||||
|
sequences: tuple[int, ...],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
wanted = set(sequences)
|
||||||
|
selected: dict[int, dict[str, Any]] = {}
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for expected_sequence, line in enumerate(stream, start=1):
|
||||||
|
if len(line) > MAX_INDEX_LINE_BYTES or not line.endswith(b"\n"):
|
||||||
|
raise MixedRouteReviewPackError("camera index line is invalid")
|
||||||
|
if expected_sequence not in wanted:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = json.loads(line)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise MixedRouteReviewPackError("camera index JSON is invalid") from exc
|
||||||
|
if (
|
||||||
|
not isinstance(value, dict)
|
||||||
|
or value.get("schema_version")
|
||||||
|
!= "missioncore.camera-recording-index/v1"
|
||||||
|
or value.get("kind") != "media"
|
||||||
|
or value.get("sequence") != expected_sequence
|
||||||
|
or value.get("path") != f"segments/{expected_sequence}.m4s"
|
||||||
|
or not isinstance(value.get("session_monotonic_ns"), int)
|
||||||
|
or not isinstance(value.get("host_monotonic_ns"), int)
|
||||||
|
or not isinstance(value.get("host_epoch_ns"), int)
|
||||||
|
):
|
||||||
|
raise MixedRouteReviewPackError("selected camera index row changed")
|
||||||
|
selected[expected_sequence] = value
|
||||||
|
if tuple(sorted(selected)) != sequences:
|
||||||
|
raise MixedRouteReviewPackError("selected camera sequence is incomplete")
|
||||||
|
return [selected[sequence] for sequence in sequences]
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_exact_fragment(
|
||||||
|
*,
|
||||||
|
ffmpeg: Path,
|
||||||
|
init_path: Path,
|
||||||
|
segment_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
) -> None:
|
||||||
|
input_value = f"concat:{init_path}|{segment_path}"
|
||||||
|
completed = subprocess.run(
|
||||||
|
[
|
||||||
|
os.fspath(ffmpeg),
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
input_value,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
os.fspath(output_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0 or not output_path.is_file():
|
||||||
|
detail = completed.stderr.strip().splitlines()[-1:] or ["no decoded frame"]
|
||||||
|
raise MixedRouteReviewPackError(
|
||||||
|
f"selected fragment is not independently decodable: {segment_path.name}: {detail[0]}"
|
||||||
|
)
|
||||||
|
with Image.open(output_path) as image:
|
||||||
|
if image.mode != "RGB" or image.size != (800, 600):
|
||||||
|
raise MixedRouteReviewPackError("selected camera frame shape changed")
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(
|
||||||
|
*,
|
||||||
|
job_root: Path,
|
||||||
|
session_root: Path,
|
||||||
|
sequences: tuple[int, ...],
|
||||||
|
output_root: Path,
|
||||||
|
ffmpeg_path: Path,
|
||||||
|
) -> Path:
|
||||||
|
job = validate_camera_compute_job(job_root)
|
||||||
|
session = session_root.resolve(strict=True)
|
||||||
|
if not session.is_dir() or session.name != job.session_id:
|
||||||
|
raise MixedRouteReviewPackError("camera job and observation session differ")
|
||||||
|
capture_root = session / "captures" / "mqtt_live"
|
||||||
|
origin_path = capture_root / "mqtt.timeline.origin.json"
|
||||||
|
origin = read_capture_clock_origin(origin_path)
|
||||||
|
if sequences[-1] > job.segment_count:
|
||||||
|
raise MixedRouteReviewPackError("selected sequence escapes the camera epoch")
|
||||||
|
ffmpeg = ffmpeg_path.resolve(strict=True)
|
||||||
|
if not ffmpeg.is_file():
|
||||||
|
raise MixedRouteReviewPackError("ffmpeg is unavailable")
|
||||||
|
epoch_root = (
|
||||||
|
job.job_root
|
||||||
|
/ "input"
|
||||||
|
/ "camera"
|
||||||
|
/ job.source_id
|
||||||
|
/ f"epoch-{job.codec_epoch}"
|
||||||
|
)
|
||||||
|
selected = _read_selected_index(epoch_root / "index.jsonl", sequences)
|
||||||
|
identity = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"job_id": job.job_id,
|
||||||
|
"input_sha256": job.input_sha256,
|
||||||
|
"session_id": job.session_id,
|
||||||
|
"source_id": job.source_id,
|
||||||
|
"codec_epoch": job.codec_epoch,
|
||||||
|
"clock_origin": {
|
||||||
|
"artifact_sha256": _sha256(origin_path),
|
||||||
|
"started_epoch_ns": origin.started_at_epoch_ns,
|
||||||
|
"started_monotonic_ns": origin.started_monotonic_ns,
|
||||||
|
},
|
||||||
|
"selected_sequences": list(sequences),
|
||||||
|
"selection_policy": "exact-independently-decodable-fragments/v1",
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": {
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
},
|
||||||
|
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
pack_id = f"mixed-route-review-pack-{identity_sha256}"
|
||||||
|
parent = output_root.resolve()
|
||||||
|
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
final = parent / pack_id
|
||||||
|
if final.exists():
|
||||||
|
return final
|
||||||
|
staging = Path(tempfile.mkdtemp(prefix=f".{pack_id}.", dir=parent))
|
||||||
|
published = False
|
||||||
|
try:
|
||||||
|
frames_root = staging / "frames"
|
||||||
|
frames_root.mkdir(mode=0o700)
|
||||||
|
timeline_rows: list[dict[str, Any]] = []
|
||||||
|
artifacts: list[dict[str, Any]] = []
|
||||||
|
for frame_index, (sequence, row) in enumerate(
|
||||||
|
zip(sequences, selected, strict=True)
|
||||||
|
):
|
||||||
|
output_path = frames_root / f"frame-{frame_index + 1:06d}.png"
|
||||||
|
segment_path = epoch_root / "segments" / f"{sequence}.m4s"
|
||||||
|
_decode_exact_fragment(
|
||||||
|
ffmpeg=ffmpeg,
|
||||||
|
init_path=epoch_root / "init.mp4",
|
||||||
|
segment_path=segment_path,
|
||||||
|
output_path=output_path,
|
||||||
|
)
|
||||||
|
host_monotonic_ns = int(row["host_monotonic_ns"])
|
||||||
|
if host_monotonic_ns < origin.started_monotonic_ns:
|
||||||
|
raise MixedRouteReviewPackError("selected frame predates the session clock origin")
|
||||||
|
session_seconds = (
|
||||||
|
host_monotonic_ns - origin.started_monotonic_ns
|
||||||
|
) / 1e9
|
||||||
|
timeline_rows.append(
|
||||||
|
{
|
||||||
|
"frame_index": frame_index,
|
||||||
|
"sequence": frame_index + 1,
|
||||||
|
"source_frame_index": sequence - 1,
|
||||||
|
"source_sequence": sequence,
|
||||||
|
"session_seconds": session_seconds,
|
||||||
|
"host_monotonic_ns": row["host_monotonic_ns"],
|
||||||
|
"host_epoch_ns": row["host_epoch_ns"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
artifacts.append(
|
||||||
|
{
|
||||||
|
"path": output_path.relative_to(staging).as_posix(),
|
||||||
|
"byte_length": output_path.stat().st_size,
|
||||||
|
"sha256": _sha256(output_path),
|
||||||
|
"source_segment_sha256": row["sha256"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
timeline_path = staging / "timeline.jsonl"
|
||||||
|
timeline_path.write_text(
|
||||||
|
"".join(
|
||||||
|
json.dumps(
|
||||||
|
row,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
for row in timeline_rows
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manifest = {
|
||||||
|
"schema_version": SCHEMA,
|
||||||
|
"pack_id": pack_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"created_at_utc": datetime.now(UTC)
|
||||||
|
.isoformat(timespec="milliseconds")
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
"frame_count": len(sequences),
|
||||||
|
"timeline": {
|
||||||
|
"path": timeline_path.name,
|
||||||
|
"byte_length": timeline_path.stat().st_size,
|
||||||
|
"sha256": _sha256(timeline_path),
|
||||||
|
},
|
||||||
|
"frames": artifacts,
|
||||||
|
}
|
||||||
|
(staging / "manifest.json").write_text(
|
||||||
|
json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(staging, final)
|
||||||
|
published = True
|
||||||
|
finally:
|
||||||
|
if not published:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
return final
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = _arguments()
|
||||||
|
result = prepare(
|
||||||
|
job_root=args.job,
|
||||||
|
session_root=args.session,
|
||||||
|
sequences=args.sequences,
|
||||||
|
output_root=args.output_root,
|
||||||
|
ffmpeg_path=args.ffmpeg,
|
||||||
|
)
|
||||||
|
print(json.dumps({"pack_id": result.name, "output": os.fspath(result)}, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seal the complete RAVNOVES004TREE semantic pass into existing LAB V1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.laboratory.mixed_route_vegetation_review import (
|
||||||
|
seal_mixed_route_full_video_review,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--base-lab-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--job-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--recorded-media-preparation", type=Path, required=True)
|
||||||
|
parser.add_argument("--eomt-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--eomt-profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--ddrnet-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(
|
||||||
|
seal_mixed_route_full_video_review(
|
||||||
|
base_lab_root=args.base_lab_root,
|
||||||
|
job_root=args.job_root,
|
||||||
|
recorded_media_preparation_path=args.recorded_media_preparation,
|
||||||
|
eomt_root=args.eomt_root,
|
||||||
|
eomt_profile_path=args.eomt_profile,
|
||||||
|
ddrnet_root=args.ddrnet_root,
|
||||||
|
output_root=args.output_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
"""Seal RAVNOVES004TREE mixed-route review into the existing vegetation LAB."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from k1link.compute.jobs import validate_camera_compute_job
|
||||||
|
|
||||||
|
from k1link.laboratory.vegetation_shadow_lab import (
|
||||||
|
LAB_SCHEMA,
|
||||||
|
RESULT_PREFIX,
|
||||||
|
VegetationShadowLabError,
|
||||||
|
canonical_json,
|
||||||
|
sha256_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
REVIEW_SCHEMA = "missioncore.mixed-route-review-pack/v1"
|
||||||
|
DDRNET_SCHEMA = "missioncore.mixed-route-ddrnet-islands/v1"
|
||||||
|
TGS_SCHEMA = "missioncore.mixed-route-tgs-result/v1"
|
||||||
|
FRAME_COUNT = 10
|
||||||
|
PHASES = (
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"rural",
|
||||||
|
"transition",
|
||||||
|
"urban",
|
||||||
|
"urban",
|
||||||
|
"urban",
|
||||||
|
"urban",
|
||||||
|
)
|
||||||
|
TGS_COLORS = {
|
||||||
|
0: (5, 7, 9),
|
||||||
|
1: (132, 188, 86),
|
||||||
|
2: (235, 112, 122),
|
||||||
|
3: (150, 154, 163),
|
||||||
|
}
|
||||||
|
FULL_ROUTE_SOURCE_ID = "RAVNOVES004TREE"
|
||||||
|
FULL_ROUTE_FRAME_COUNT = 6830
|
||||||
|
FULL_ROUTE_JOB_ID = "recorded-camera-eb2783c5480d56bda07c8af0"
|
||||||
|
FULL_ROUTE_INPUT_SHA256 = (
|
||||||
|
"eb2783c5480d56bda07c8af008dff5344d19dc550ef70fe2075d6f098f7cc715"
|
||||||
|
)
|
||||||
|
FULL_ROUTE_STREAM_SHA256 = (
|
||||||
|
"e5eb017e2cc0f546736eda5235ca157b501913093cb64af5e548e335417e1bac"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise VegetationShadowLabError(f"{label} is invalid") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise VegetationShadowLabError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact(
|
||||||
|
source: Path,
|
||||||
|
staging: Path,
|
||||||
|
relative: str,
|
||||||
|
artifacts: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
media_type: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if source.is_symlink() or not source.is_file():
|
||||||
|
raise VegetationShadowLabError(f"mixed-route artifact is unavailable: {relative}")
|
||||||
|
target = staging / relative
|
||||||
|
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(source, target)
|
||||||
|
descriptor = {
|
||||||
|
"role": role,
|
||||||
|
"path": relative,
|
||||||
|
"byte_length": target.stat().st_size,
|
||||||
|
"sha256": sha256_path(target),
|
||||||
|
"media_type": media_type,
|
||||||
|
}
|
||||||
|
artifacts.append(descriptor)
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def _image_proof(descriptor: dict[str, object]) -> dict[str, object]:
|
||||||
|
return {"path": descriptor["path"], "sha256": descriptor["sha256"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_archive_descriptor(
|
||||||
|
path: Path,
|
||||||
|
relative: str,
|
||||||
|
artifacts: list[dict[str, object]],
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
descriptor = {
|
||||||
|
"role": role,
|
||||||
|
"path": relative,
|
||||||
|
"byte_length": path.stat().st_size,
|
||||||
|
"sha256": sha256_path(path),
|
||||||
|
"media_type": "application/zip",
|
||||||
|
}
|
||||||
|
artifacts.append(descriptor)
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
def _repack_eomt_masks(source: Path, destination: Path, frame_count: int) -> None:
|
||||||
|
destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
expected = [f"semantic-masks/frame-{sequence + 1:06d}.png" for sequence in range(frame_count)]
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
tarfile.open(source, mode="r:gz") as archive,
|
||||||
|
zipfile.ZipFile(
|
||||||
|
destination,
|
||||||
|
mode="x",
|
||||||
|
compression=zipfile.ZIP_STORED,
|
||||||
|
allowZip64=True,
|
||||||
|
) as output,
|
||||||
|
):
|
||||||
|
members = [member for member in archive.getmembers() if member.isfile()]
|
||||||
|
if [member.name.removeprefix("./") for member in members] != expected:
|
||||||
|
raise VegetationShadowLabError("full-route EoMT mask sequence changed")
|
||||||
|
for member, expected_name in zip(members, expected, strict=True):
|
||||||
|
if member.size < 8 or member.size > 1024 * 1024:
|
||||||
|
raise VegetationShadowLabError("full-route EoMT mask size changed")
|
||||||
|
stream = archive.extractfile(member)
|
||||||
|
if stream is None:
|
||||||
|
raise VegetationShadowLabError("full-route EoMT mask is unavailable")
|
||||||
|
output.writestr(
|
||||||
|
f"masks/{Path(expected_name).name}",
|
||||||
|
stream.read(),
|
||||||
|
)
|
||||||
|
except (OSError, tarfile.TarError, zipfile.BadZipFile) as exc:
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise VegetationShadowLabError("full-route EoMT archive is invalid") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_zip_masks(path: Path, frame_count: int) -> None:
|
||||||
|
expected = [f"masks/frame-{sequence + 1:06d}.png" for sequence in range(frame_count)]
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path) as archive:
|
||||||
|
members = archive.infolist()
|
||||||
|
if (
|
||||||
|
[member.filename for member in members] != expected
|
||||||
|
or any(
|
||||||
|
member.is_dir() or member.file_size < 8 or member.file_size > 1024 * 1024
|
||||||
|
for member in members
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route semantic mask sequence changed")
|
||||||
|
except (OSError, zipfile.BadZipFile) as exc:
|
||||||
|
raise VegetationShadowLabError("full-route semantic archive is invalid") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _full_route_frame_times(media: dict[str, Any], frame_count: int) -> list[int]:
|
||||||
|
epochs = media.get("epochs")
|
||||||
|
start = media.get("timeline_start_seconds")
|
||||||
|
end = media.get("timeline_end_seconds")
|
||||||
|
if (
|
||||||
|
not isinstance(epochs, list)
|
||||||
|
or len(epochs) != 1
|
||||||
|
or not isinstance(start, (int, float))
|
||||||
|
or not isinstance(end, (int, float))
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("recorded media timeline changed")
|
||||||
|
epoch = epochs[0]
|
||||||
|
segments = epoch.get("segments") if isinstance(epoch, dict) else None
|
||||||
|
if not isinstance(segments, list) or len(segments) != frame_count:
|
||||||
|
raise VegetationShadowLabError("recorded media segment count changed")
|
||||||
|
starts = [float(start)]
|
||||||
|
previous_end = 0.0
|
||||||
|
for sequence, raw in enumerate(segments, start=1):
|
||||||
|
if (
|
||||||
|
not isinstance(raw, dict)
|
||||||
|
or raw.get("sequence") != sequence
|
||||||
|
or not isinstance(raw.get("end_time_seconds"), (int, float))
|
||||||
|
or float(raw["end_time_seconds"]) <= previous_end
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("recorded media segment timeline changed")
|
||||||
|
if sequence < frame_count:
|
||||||
|
starts.append(float(start) + float(raw["end_time_seconds"]))
|
||||||
|
previous_end = float(raw["end_time_seconds"])
|
||||||
|
if abs((float(start) + previous_end) - float(end)) > 0.001:
|
||||||
|
raise VegetationShadowLabError("recorded media duration changed")
|
||||||
|
return [round(value * 1_000_000_000) for value in starts]
|
||||||
|
|
||||||
|
|
||||||
|
def _eomt_taxonomy(profile: dict[str, Any]) -> dict[str, object]:
|
||||||
|
taxonomy = profile.get("target_taxonomy")
|
||||||
|
if not isinstance(taxonomy, dict) or set(taxonomy) != {str(index) for index in range(16)}:
|
||||||
|
raise VegetationShadowLabError("EoMT target taxonomy changed")
|
||||||
|
classes = []
|
||||||
|
for class_id in range(16):
|
||||||
|
digest = hashlib.sha256(f"mission-core-segment-{class_id}".encode()).digest()
|
||||||
|
classes.append(
|
||||||
|
{
|
||||||
|
"class_id": class_id,
|
||||||
|
"label": taxonomy[str(class_id)],
|
||||||
|
"color_rgb": [64 + digest[index] % 176 for index in range(3)],
|
||||||
|
"disposition": "undefined" if class_id == 0 else "prediction",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-eomt-taxonomy/v1",
|
||||||
|
"classes": classes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_tgs_costmaps(tgs_root: Path, destination: Path) -> list[Path]:
|
||||||
|
result = _read_json(tgs_root / "result.json", "mixed-route TGS result")
|
||||||
|
evidence = result.get("evidence")
|
||||||
|
costmap = result.get("costmap")
|
||||||
|
if (
|
||||||
|
result.get("schema_version") != TGS_SCHEMA
|
||||||
|
or result.get("status") != "passed-review-only"
|
||||||
|
or not isinstance(evidence, dict)
|
||||||
|
or not isinstance(costmap, dict)
|
||||||
|
or result.get("summary", {}).get("frame_count") != FRAME_COUNT
|
||||||
|
or result.get("authority", {}).get("actuation_allowed") is not False
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route TGS contract changed")
|
||||||
|
evidence_path = tgs_root / str(evidence.get("path"))
|
||||||
|
if (
|
||||||
|
not evidence_path.is_file()
|
||||||
|
or evidence.get("bytes") != evidence_path.stat().st_size
|
||||||
|
or evidence.get("sha256") != sha256_path(evidence_path)
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route TGS evidence changed")
|
||||||
|
with np.load(evidence_path, allow_pickle=False) as archive:
|
||||||
|
centers = archive["costmap_cell_centers_xy_m"]
|
||||||
|
states = archive["causal_rolling_1s_costmap_states"]
|
||||||
|
if centers.shape != (2244, 2) or states.shape != (FRAME_COUNT, 2244):
|
||||||
|
raise VegetationShadowLabError("mixed-route TGS costmap shape changed")
|
||||||
|
radius = float(costmap["radius_m"])
|
||||||
|
cell_size = float(costmap["cell_size_m"])
|
||||||
|
size = 600
|
||||||
|
scale = size / (radius * 2.0)
|
||||||
|
outputs: list[Path] = []
|
||||||
|
destination.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
for slot in range(FRAME_COUNT):
|
||||||
|
image = Image.new("RGB", (size, size), TGS_COLORS[0])
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
half = cell_size * scale / 2.0
|
||||||
|
for center, state in zip(centers, states[slot], strict=True):
|
||||||
|
x = (float(center[0]) + radius) * scale
|
||||||
|
y = (radius - float(center[1])) * scale
|
||||||
|
draw.rectangle((x - half, y - half, x + half, y + half), fill=TGS_COLORS[int(state)])
|
||||||
|
rover_w = 0.8 * scale
|
||||||
|
rover_l = 1.0 * scale
|
||||||
|
cx = size / 2.0
|
||||||
|
cy = size / 2.0
|
||||||
|
draw.rectangle(
|
||||||
|
(cx - rover_w / 2, cy - rover_l / 2, cx + rover_w / 2, cy + rover_l / 2),
|
||||||
|
outline=(255, 255, 255),
|
||||||
|
width=3,
|
||||||
|
)
|
||||||
|
path = destination / f"frame-{slot + 1:06d}.png"
|
||||||
|
image.save(path, format="PNG", optimize=True)
|
||||||
|
outputs.append(path)
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
|
def seal_mixed_route_vegetation_review(
|
||||||
|
*,
|
||||||
|
base_lab_root: Path,
|
||||||
|
review_pack_root: Path,
|
||||||
|
eomt_root: Path,
|
||||||
|
ddrnet_root: Path,
|
||||||
|
tgs_root: Path,
|
||||||
|
output_root: Path,
|
||||||
|
) -> Path:
|
||||||
|
base_root = base_lab_root.resolve(strict=True)
|
||||||
|
base = _read_json(base_root / "result.json", "base vegetation LAB")
|
||||||
|
base_identity = base.get("identity")
|
||||||
|
if (
|
||||||
|
base.get("schema_version") != LAB_SCHEMA
|
||||||
|
or not isinstance(base_identity, dict)
|
||||||
|
or hashlib.sha256(canonical_json(base_identity)).hexdigest()
|
||||||
|
!= base.get("identity_sha256")
|
||||||
|
or base.get("result_id") != base_root.name
|
||||||
|
or not base_root.name.startswith(RESULT_PREFIX)
|
||||||
|
or base.get("authority", {}).get("commands_enabled") is not False
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("base vegetation LAB proof changed")
|
||||||
|
|
||||||
|
pack_root = review_pack_root.resolve(strict=True)
|
||||||
|
pack = _read_json(pack_root / "manifest.json", "mixed-route review pack")
|
||||||
|
timeline_path = pack_root / str(pack.get("timeline", {}).get("path"))
|
||||||
|
if (
|
||||||
|
pack.get("schema_version") != REVIEW_SCHEMA
|
||||||
|
or pack.get("frame_count") != FRAME_COUNT
|
||||||
|
or pack.get("identity", {}).get("session_id") != "20260828T130511Z_viewer_live"
|
||||||
|
or pack.get("identity", {}).get("ground_truth") is not False
|
||||||
|
or not timeline_path.is_file()
|
||||||
|
or pack.get("timeline", {}).get("sha256") != sha256_path(timeline_path)
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route review pack changed")
|
||||||
|
timeline = [json.loads(line) for line in timeline_path.read_text(encoding="utf-8").splitlines()]
|
||||||
|
if len(timeline) != FRAME_COUNT:
|
||||||
|
raise VegetationShadowLabError("mixed-route timeline is incomplete")
|
||||||
|
|
||||||
|
eomt = _read_json(eomt_root / "run-report.partial.json", "mixed-route EoMT result")
|
||||||
|
ddrnet = _read_json(ddrnet_root / "result.json", "mixed-route DDRNet result")
|
||||||
|
tgs = _read_json(tgs_root / "result.json", "mixed-route TGS result")
|
||||||
|
if (
|
||||||
|
eomt.get("input", {}).get("frames_admitted") != FRAME_COUNT
|
||||||
|
or eomt.get("metrics", {}).get("frames_processed") != FRAME_COUNT
|
||||||
|
or eomt.get("ground_truth") is not False
|
||||||
|
or ddrnet.get("schema_version") != DDRNET_SCHEMA
|
||||||
|
or ddrnet.get("source", {}).get("pack_id") != pack["pack_id"]
|
||||||
|
or len(ddrnet.get("frames", [])) != FRAME_COUNT
|
||||||
|
or ddrnet.get("authority", {}).get("candidate_accepted") is not False
|
||||||
|
or tgs.get("schema_version") != TGS_SCHEMA
|
||||||
|
or tgs.get("source", {}).get("review_pack_id") != pack["pack_id"]
|
||||||
|
or tgs.get("summary", {}).get("frame_count") != FRAME_COUNT
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("mixed-route model identities differ")
|
||||||
|
|
||||||
|
output_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary = Path(tempfile.mkdtemp(prefix=".mixed-route-vegetation-", dir=output_root))
|
||||||
|
artifacts: list[dict[str, object]] = []
|
||||||
|
try:
|
||||||
|
tgs_images = _render_tgs_costmaps(tgs_root, temporary / ".tgs-render")
|
||||||
|
cases: list[dict[str, object]] = []
|
||||||
|
tgs_anchors = {
|
||||||
|
int(row["slot"]): row
|
||||||
|
for row in tgs["anchors"]
|
||||||
|
if row.get("profile_id") == "causal_rolling_1s"
|
||||||
|
}
|
||||||
|
for slot, row in enumerate(timeline):
|
||||||
|
case_id = f"route-{slot + 1:02d}"
|
||||||
|
relative_root = f"route-review/{case_id}"
|
||||||
|
source_descriptor = _artifact(
|
||||||
|
pack_root / "frames" / f"frame-{slot + 1:06d}.png",
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/source.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-source-frame",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
city_descriptor = _artifact(
|
||||||
|
eomt_root / "overlay-frames" / f"frame-{slot + 1:06d}.png",
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/city.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-eomt-overlay",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
vegetation_descriptor = _artifact(
|
||||||
|
ddrnet_root / "overlay-frames" / f"frame-{slot + 1:06d}.png",
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/vegetation.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-ddrnet-overlay",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
tgs_descriptor = _artifact(
|
||||||
|
tgs_images[slot],
|
||||||
|
temporary,
|
||||||
|
f"{relative_root}/tgs.png",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-tgs-costmap",
|
||||||
|
media_type="image/png",
|
||||||
|
)
|
||||||
|
anchor = tgs_anchors[slot]
|
||||||
|
cases.append(
|
||||||
|
{
|
||||||
|
"case_id": case_id,
|
||||||
|
"phase": PHASES[slot],
|
||||||
|
"source_sequence": int(row["source_sequence"]),
|
||||||
|
"session_seconds": float(row["session_seconds"]),
|
||||||
|
"assets": {
|
||||||
|
"source": _image_proof(source_descriptor),
|
||||||
|
"city": _image_proof(city_descriptor),
|
||||||
|
"vegetation": _image_proof(vegetation_descriptor),
|
||||||
|
"tgs": _image_proof(tgs_descriptor),
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"ground_cells": int(anchor["ground_cell_count"]),
|
||||||
|
"occupied_cells": int(anchor["nonground_cell_count"]),
|
||||||
|
"rejected_cells": int(anchor["rejected_cell_count"]),
|
||||||
|
"unobserved_cells": int(anchor["unobserved_cell_count"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
shutil.rmtree(temporary / ".tgs-render")
|
||||||
|
|
||||||
|
proofs = {}
|
||||||
|
for key, path in (
|
||||||
|
("base", base_root / "result.json"),
|
||||||
|
("eomt", eomt_root / "run-report.partial.json"),
|
||||||
|
("ddrnet", ddrnet_root / "result.json"),
|
||||||
|
("tgs", tgs_root / "result.json"),
|
||||||
|
):
|
||||||
|
descriptor = _artifact(
|
||||||
|
path,
|
||||||
|
temporary,
|
||||||
|
f"proofs/{key}.json",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-proof",
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
proofs[key] = _image_proof(descriptor)
|
||||||
|
_artifact(
|
||||||
|
tgs_root / str(tgs["evidence"]["path"]),
|
||||||
|
temporary,
|
||||||
|
"proofs/tgs-evidence.npz",
|
||||||
|
artifacts,
|
||||||
|
role="mixed-route-tgs-evidence",
|
||||||
|
media_type="application/x-npz",
|
||||||
|
)
|
||||||
|
|
||||||
|
route_review = {
|
||||||
|
"source_id": "RAVNOVES004TREE",
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"pack_id": pack["pack_id"],
|
||||||
|
"frame_count": FRAME_COUNT,
|
||||||
|
"ground_truth": False,
|
||||||
|
"selection_policy": "same-scene-camera-lidar-aligned-review-islands/v1",
|
||||||
|
"models": {
|
||||||
|
"city": {
|
||||||
|
"name": "EoMT Cityscapes",
|
||||||
|
"frames": FRAME_COUNT,
|
||||||
|
"inference_fps": eomt["metrics"]["inference_frames_per_second"],
|
||||||
|
"end_to_end_p95_ms": eomt["metrics"]["latency_ms"]["end_to_end_ms"]["p95"],
|
||||||
|
},
|
||||||
|
"vegetation": {
|
||||||
|
"name": ddrnet["candidate"]["loaded_model_name"],
|
||||||
|
"result_id": ddrnet["result_id"],
|
||||||
|
"frames": FRAME_COUNT,
|
||||||
|
"latency_p95_ms": ddrnet["timing"]["latency_ms_p95"],
|
||||||
|
},
|
||||||
|
"tgs": {
|
||||||
|
"name": "TRAVEL/TGS causal rolling 1 s",
|
||||||
|
"frames": FRAME_COUNT,
|
||||||
|
"latency_p95_ms": tgs["timing"]["wall_seconds_p95"] * 1000.0,
|
||||||
|
"cell_size_m": tgs["costmap"]["cell_size_m"],
|
||||||
|
"radius_m": tgs["costmap"]["radius_m"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"cases": cases,
|
||||||
|
"proofs": proofs,
|
||||||
|
"limitations": [
|
||||||
|
"Ten aligned review islands are not a complete route timeline.",
|
||||||
|
"RAVNOVES004TREE has no manual truth.",
|
||||||
|
"DDRNet vegetation subtypes remain visually noisy and are not planner authority.",
|
||||||
|
"TGS does not prove ditch or negative-obstacle detection.",
|
||||||
|
"People and vehicles require an independent fail-safe detector and STOP path.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
authority = {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_accepted": False,
|
||||||
|
"camera_semantics_can_clear_rigid_geometry": False,
|
||||||
|
}
|
||||||
|
identity = {
|
||||||
|
"lab_id": "lab-v1-vegetation-mission-policy",
|
||||||
|
"base_result_id": base["result_id"],
|
||||||
|
"selected_candidate": base_identity["selected_candidate"],
|
||||||
|
"candidate_metrics": base_identity["candidate_metrics"],
|
||||||
|
"source": {
|
||||||
|
"shadow_session": "RAVNOVES004TREE",
|
||||||
|
"shadow_camera": "sensor.camera.right",
|
||||||
|
"shadow_frame_count": FRAME_COUNT,
|
||||||
|
"video_shadow_frame_count": 0,
|
||||||
|
},
|
||||||
|
"route_review": route_review,
|
||||||
|
"authority": authority,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||||
|
result_id = f"{RESULT_PREFIX}{identity_sha256}"
|
||||||
|
manifest = {
|
||||||
|
"schema_version": LAB_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"created_at_utc": datetime.now(UTC).isoformat(),
|
||||||
|
"ground_truth": False,
|
||||||
|
"status": "visual-shadow-ready-policy-not-authorized",
|
||||||
|
"identity": identity,
|
||||||
|
"source": identity["source"],
|
||||||
|
"route_video": None,
|
||||||
|
"route_review": route_review,
|
||||||
|
"method": {
|
||||||
|
"completeness": "bounded-review-islands",
|
||||||
|
"execution_class": "ai-inference",
|
||||||
|
"pipeline_id": "ravnoves004tree-eomt-ddrnet-causal-tgs-review/v1",
|
||||||
|
},
|
||||||
|
"metrics": {"candidates": base["metrics"]["candidates"]},
|
||||||
|
"decision": {
|
||||||
|
"selected_candidate": base_identity["selected_candidate"],
|
||||||
|
"visual_shadow_ready": True,
|
||||||
|
"full_video_shadow_ready": False,
|
||||||
|
"mission_policy_ready_for_configuration": True,
|
||||||
|
"multilayer_policy_review_ready": True,
|
||||||
|
"navigation_accepted": False,
|
||||||
|
"production_accepted": False,
|
||||||
|
},
|
||||||
|
"limitations": route_review["limitations"],
|
||||||
|
"authority": authority,
|
||||||
|
"catalogs": {"goose": [], "ravnoves": []},
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
(temporary / "result.json").write_bytes(canonical_json(manifest) + b"\n")
|
||||||
|
destination = output_root / result_id
|
||||||
|
if destination.exists():
|
||||||
|
raise VegetationShadowLabError("immutable mixed-route LAB result already exists")
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
return destination
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def seal_mixed_route_full_video_review(
|
||||||
|
*,
|
||||||
|
base_lab_root: Path,
|
||||||
|
job_root: Path,
|
||||||
|
recorded_media_preparation_path: Path,
|
||||||
|
eomt_root: Path,
|
||||||
|
eomt_profile_path: Path,
|
||||||
|
ddrnet_root: Path,
|
||||||
|
output_root: Path,
|
||||||
|
) -> Path:
|
||||||
|
"""Publish the complete 004 city/nature pass in the existing M4.7 LAB."""
|
||||||
|
|
||||||
|
base_root = base_lab_root.resolve(strict=True)
|
||||||
|
base = _read_json(base_root / "result.json", "base vegetation LAB")
|
||||||
|
base_identity = base.get("identity")
|
||||||
|
if (
|
||||||
|
base.get("schema_version") != LAB_SCHEMA
|
||||||
|
or not isinstance(base_identity, dict)
|
||||||
|
or hashlib.sha256(canonical_json(base_identity)).hexdigest()
|
||||||
|
!= base.get("identity_sha256")
|
||||||
|
or base.get("result_id") != base_root.name
|
||||||
|
or not base_root.name.startswith(RESULT_PREFIX)
|
||||||
|
or base.get("authority", {}).get("commands_enabled") is not False
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("base vegetation LAB proof changed")
|
||||||
|
|
||||||
|
job = validate_camera_compute_job(job_root)
|
||||||
|
if (
|
||||||
|
job.job_id != FULL_ROUTE_JOB_ID
|
||||||
|
or job.input_sha256 != FULL_ROUTE_INPUT_SHA256
|
||||||
|
or job.session_id != "20260828T130511Z_viewer_live"
|
||||||
|
or job.source_id != "sensor.camera.right"
|
||||||
|
or job.segment_count != FULL_ROUTE_FRAME_COUNT
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route camera job changed")
|
||||||
|
|
||||||
|
eomt = _read_json(eomt_root / "result.json", "full-route EoMT result")
|
||||||
|
eomt_report = _read_json(eomt_root / "run-report.json", "full-route EoMT report")
|
||||||
|
decode_repair = _read_json(
|
||||||
|
eomt_root / "decode-repair.json",
|
||||||
|
"full-route video decode repair",
|
||||||
|
)
|
||||||
|
eomt_input = eomt_report.get("input")
|
||||||
|
eomt_metrics = eomt_report.get("metrics")
|
||||||
|
if (
|
||||||
|
eomt.get("schema_version") != "missioncore.recorded-perception-result/v2"
|
||||||
|
or eomt.get("ground_truth") is not False
|
||||||
|
or eomt.get("frames_processed") != FULL_ROUTE_FRAME_COUNT
|
||||||
|
or not isinstance(eomt_input, dict)
|
||||||
|
or eomt_input.get("job_id") != job.job_id
|
||||||
|
or eomt_input.get("input_sha256") != job.input_sha256
|
||||||
|
or eomt_input.get("frames_admitted") != FULL_ROUTE_FRAME_COUNT
|
||||||
|
or not isinstance(eomt_metrics, dict)
|
||||||
|
or eomt_metrics.get("frames_processed") != FULL_ROUTE_FRAME_COUNT
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route EoMT contract changed")
|
||||||
|
if (
|
||||||
|
decode_repair.get("schema_version")
|
||||||
|
!= "missioncore.recorded-video-decode-repair/v1"
|
||||||
|
or decode_repair.get("decoder") != "ffmpeg-h264_cuvid-output-corrupt"
|
||||||
|
or decode_repair.get("packets_requested") != FULL_ROUTE_FRAME_COUNT
|
||||||
|
or decode_repair.get("frames_decoded") != FULL_ROUTE_FRAME_COUNT - 1
|
||||||
|
or decode_repair.get("repaired_frame_count") != 1
|
||||||
|
or decode_repair.get("repairs")
|
||||||
|
!= [
|
||||||
|
{
|
||||||
|
"sequence": 6092,
|
||||||
|
"packet_pts": 55656450,
|
||||||
|
"method": "duplicate-previous-decoded-frame",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route video decode repair changed")
|
||||||
|
eomt_artifacts = {
|
||||||
|
item.get("kind"): item
|
||||||
|
for item in eomt.get("artifacts", [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
}
|
||||||
|
eomt_archive_proof = eomt_artifacts.get("panoptic-mask-archive")
|
||||||
|
if not isinstance(eomt_archive_proof, dict):
|
||||||
|
raise VegetationShadowLabError("full-route EoMT mask proof is missing")
|
||||||
|
eomt_archive = eomt_root / str(eomt_archive_proof.get("path"))
|
||||||
|
if (
|
||||||
|
not eomt_archive.is_file()
|
||||||
|
or eomt_archive.stat().st_size != eomt_archive_proof.get("byte_length")
|
||||||
|
or sha256_path(eomt_archive) != eomt_archive_proof.get("sha256")
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route EoMT mask proof changed")
|
||||||
|
|
||||||
|
ddrnet = _read_json(ddrnet_root / "result.json", "full-route DDRNet result")
|
||||||
|
ddrnet_decode_repair = _read_json(
|
||||||
|
ddrnet_root / "decode-repair.json",
|
||||||
|
"full-route DDRNet video decode repair",
|
||||||
|
)
|
||||||
|
ddrnet_source = ddrnet.get("source")
|
||||||
|
ddrnet_video = ddrnet.get("video_semantics")
|
||||||
|
if (
|
||||||
|
ddrnet.get("schema_version") != "missioncore.lab-v1-goose-vegetation-run/v1"
|
||||||
|
or ddrnet.get("mode") != "ravnoves-video"
|
||||||
|
or ddrnet.get("candidate", {}).get("candidate_key") != "ddrnet"
|
||||||
|
or not isinstance(ddrnet_source, dict)
|
||||||
|
or ddrnet_source.get("source_id")
|
||||||
|
!= f"{FULL_ROUTE_SOURCE_ID}/right-{FULL_ROUTE_STREAM_SHA256}"
|
||||||
|
or ddrnet_source.get("input_count") != FULL_ROUTE_FRAME_COUNT
|
||||||
|
or ddrnet_source.get("ground_truth_available") is not False
|
||||||
|
or not isinstance(ddrnet_video, dict)
|
||||||
|
or ddrnet_video.get("base_m4_result_id") is not None
|
||||||
|
or ddrnet.get("authority", {}).get("navigation_accepted") is not False
|
||||||
|
or ddrnet.get("authority", {}).get("actuation_accepted") is not False
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route DDRNet contract changed")
|
||||||
|
if ddrnet_decode_repair != decode_repair:
|
||||||
|
raise VegetationShadowLabError("full-route model decoders disagree")
|
||||||
|
ddrnet_archive_proof = ddrnet_video.get("mask_archive")
|
||||||
|
ddrnet_taxonomy = ddrnet_video.get("taxonomy")
|
||||||
|
if (
|
||||||
|
not isinstance(ddrnet_archive_proof, dict)
|
||||||
|
or ddrnet_archive_proof.get("frame_count") != FULL_ROUTE_FRAME_COUNT
|
||||||
|
or not isinstance(ddrnet_taxonomy, dict)
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route DDRNet mask proof changed")
|
||||||
|
ddrnet_archive = ddrnet_root / str(ddrnet_archive_proof.get("path"))
|
||||||
|
if (
|
||||||
|
not ddrnet_archive.is_file()
|
||||||
|
or ddrnet_archive.stat().st_size != ddrnet_archive_proof.get("byte_length")
|
||||||
|
or sha256_path(ddrnet_archive) != ddrnet_archive_proof.get("sha256")
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("full-route DDRNet archive changed")
|
||||||
|
_validate_zip_masks(ddrnet_archive, FULL_ROUTE_FRAME_COUNT)
|
||||||
|
|
||||||
|
media_document = _read_json(
|
||||||
|
recorded_media_preparation_path.resolve(strict=True),
|
||||||
|
"recorded media preparation",
|
||||||
|
)
|
||||||
|
media = media_document.get("manifest")
|
||||||
|
if (
|
||||||
|
media_document.get("schema_version") != "missioncore.recorded-media-preparation/v3"
|
||||||
|
or media_document.get("session_id") != job.session_id
|
||||||
|
or media_document.get("artifact_id") != "recorded-video-6a3945242828a038"
|
||||||
|
or media_document.get("checksum_sha256")
|
||||||
|
!= "557e61f2839140dc9f97b5aea855c576b0616573080dff5d2852ab1df0558665"
|
||||||
|
or not isinstance(media, dict)
|
||||||
|
or media.get("source_id") != "recorded.camera.6a3945242828a038"
|
||||||
|
or media.get("generation_sha256")
|
||||||
|
!= "b073ea1e7babf1c77a664e1a5b95e3702d0e05b0e34c1e85a7c67a6f8b392ded"
|
||||||
|
or media.get("byte_length") != 551674491
|
||||||
|
or media.get("timeline_start_seconds") != job.timeline_start_seconds
|
||||||
|
or media.get("timeline_end_seconds") != job.timeline_end_seconds
|
||||||
|
or media.get("synchronization") != "host-arrival-best-effort"
|
||||||
|
):
|
||||||
|
raise VegetationShadowLabError("recorded media preparation changed")
|
||||||
|
frame_times_ns = _full_route_frame_times(media, FULL_ROUTE_FRAME_COUNT)
|
||||||
|
eomt_profile = _read_json(eomt_profile_path.resolve(strict=True), "EoMT profile")
|
||||||
|
eomt_taxonomy = _eomt_taxonomy(eomt_profile)
|
||||||
|
|
||||||
|
output_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
temporary = Path(tempfile.mkdtemp(prefix=".mixed-route-full-video-", dir=output_root))
|
||||||
|
artifacts: list[dict[str, object]] = []
|
||||||
|
try:
|
||||||
|
eomt_destination = temporary / "video" / "eomt-semantic-masks.zip"
|
||||||
|
_repack_eomt_masks(eomt_archive, eomt_destination, FULL_ROUTE_FRAME_COUNT)
|
||||||
|
_validate_zip_masks(eomt_destination, FULL_ROUTE_FRAME_COUNT)
|
||||||
|
eomt_descriptor = _mask_archive_descriptor(
|
||||||
|
eomt_destination,
|
||||||
|
"video/eomt-semantic-masks.zip",
|
||||||
|
artifacts,
|
||||||
|
role="full-route-eomt-semantic-mask-archive",
|
||||||
|
)
|
||||||
|
ddrnet_descriptor = _artifact(
|
||||||
|
ddrnet_archive,
|
||||||
|
temporary,
|
||||||
|
"video/ddrnet-semantic-masks.zip",
|
||||||
|
artifacts,
|
||||||
|
role="full-route-ddrnet-semantic-mask-archive",
|
||||||
|
media_type="application/zip",
|
||||||
|
)
|
||||||
|
_validate_zip_masks(
|
||||||
|
temporary / "video" / "ddrnet-semantic-masks.zip",
|
||||||
|
FULL_ROUTE_FRAME_COUNT,
|
||||||
|
)
|
||||||
|
timeline_destination = temporary / "video" / "frame-source-times-ns.bin"
|
||||||
|
timeline_destination.write_bytes(
|
||||||
|
struct.pack(f"<{FULL_ROUTE_FRAME_COUNT}Q", *frame_times_ns)
|
||||||
|
)
|
||||||
|
timeline_descriptor = {
|
||||||
|
"role": "full-route-frame-timeline",
|
||||||
|
"path": "video/frame-source-times-ns.bin",
|
||||||
|
"byte_length": timeline_destination.stat().st_size,
|
||||||
|
"sha256": sha256_path(timeline_destination),
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
}
|
||||||
|
artifacts.append(timeline_descriptor)
|
||||||
|
proof_descriptors: dict[str, dict[str, object]] = {}
|
||||||
|
for key, path in (
|
||||||
|
("base", base_root / "result.json"),
|
||||||
|
("job", job.manifest_path),
|
||||||
|
("media", recorded_media_preparation_path.resolve(strict=True)),
|
||||||
|
("eomt", eomt_root / "result.json"),
|
||||||
|
("eomt_report", eomt_root / "run-report.json"),
|
||||||
|
("decode_repair", eomt_root / "decode-repair.json"),
|
||||||
|
("ddrnet", ddrnet_root / "result.json"),
|
||||||
|
("ddrnet_decode_repair", ddrnet_root / "decode-repair.json"),
|
||||||
|
):
|
||||||
|
descriptor = _artifact(
|
||||||
|
path,
|
||||||
|
temporary,
|
||||||
|
f"proofs/{key}.json",
|
||||||
|
artifacts,
|
||||||
|
role="full-route-proof",
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
proof_descriptors[key] = _image_proof(descriptor)
|
||||||
|
|
||||||
|
full_route = {
|
||||||
|
"source_id": FULL_ROUTE_SOURCE_ID,
|
||||||
|
"session_id": job.session_id,
|
||||||
|
"linked_route_review_result_id": base["result_id"],
|
||||||
|
"source_job_id": job.job_id,
|
||||||
|
"source_job_input_sha256": job.input_sha256,
|
||||||
|
"source_stream_sha256": FULL_ROUTE_STREAM_SHA256,
|
||||||
|
"recorded_media_source_id": media["source_id"],
|
||||||
|
"recorded_media_generation_sha256": media["generation_sha256"],
|
||||||
|
"frame_count": FULL_ROUTE_FRAME_COUNT,
|
||||||
|
"width": 800,
|
||||||
|
"height": 600,
|
||||||
|
"timeline_start_seconds": job.timeline_start_seconds,
|
||||||
|
"timeline_end_seconds": job.timeline_end_seconds,
|
||||||
|
"timeline": {
|
||||||
|
"path": timeline_descriptor["path"],
|
||||||
|
"sha256": timeline_descriptor["sha256"],
|
||||||
|
"byte_length": timeline_descriptor["byte_length"],
|
||||||
|
"encoding": "uint64-le-nanoseconds",
|
||||||
|
"frame_count": FULL_ROUTE_FRAME_COUNT,
|
||||||
|
},
|
||||||
|
"ground_truth": False,
|
||||||
|
"decode_repair": {
|
||||||
|
"repaired_frame_count": 1,
|
||||||
|
"sequence": 6092,
|
||||||
|
"method": "duplicate-previous-decoded-frame",
|
||||||
|
"proofs": {
|
||||||
|
"eomt": proof_descriptors["decode_repair"],
|
||||||
|
"ddrnet": proof_descriptors["ddrnet_decode_repair"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"layers": {
|
||||||
|
"city": {
|
||||||
|
"name": "EoMT Cityscapes",
|
||||||
|
"result_id": eomt["result_id"],
|
||||||
|
"frame_count": FULL_ROUTE_FRAME_COUNT,
|
||||||
|
"taxonomy": eomt_taxonomy,
|
||||||
|
"mask_archive": {
|
||||||
|
"path": eomt_descriptor["path"],
|
||||||
|
"sha256": eomt_descriptor["sha256"],
|
||||||
|
"byte_length": eomt_descriptor["byte_length"],
|
||||||
|
},
|
||||||
|
"inference_fps": eomt_metrics["inference_frames_per_second"],
|
||||||
|
"latency_p95_ms": eomt_metrics["latency_ms"]["end_to_end_ms"]["p95"],
|
||||||
|
"peak_reserved_vram_bytes": int(
|
||||||
|
float(eomt_metrics["cuda_peak_memory_reserved_mib"]) * 1024 * 1024
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"vegetation": {
|
||||||
|
"name": ddrnet["candidate"]["loaded_model_name"],
|
||||||
|
"result_id": ddrnet["result_id"],
|
||||||
|
"frame_count": FULL_ROUTE_FRAME_COUNT,
|
||||||
|
"taxonomy": ddrnet_taxonomy,
|
||||||
|
"mask_archive": {
|
||||||
|
"path": ddrnet_descriptor["path"],
|
||||||
|
"sha256": ddrnet_descriptor["sha256"],
|
||||||
|
"byte_length": ddrnet_descriptor["byte_length"],
|
||||||
|
},
|
||||||
|
"inference_fps": ddrnet["timing"]["throughput_fps_from_mean_inference"],
|
||||||
|
"latency_p95_ms": ddrnet["timing"]["latency_ms_p95"],
|
||||||
|
"peak_reserved_vram_bytes": ddrnet["resource"]["peak_reserved_vram_bytes"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"proofs": proof_descriptors,
|
||||||
|
"limitations": [
|
||||||
|
"RAVNOVES004TREE has no manual route truth.",
|
||||||
|
"One corrupt H.264 packet at sequence 6092 was represented by the previous decoded frame; the repair is sealed as evidence.",
|
||||||
|
"EoMT and DDRNet were executed sequentially, not as a concurrent realtime stack.",
|
||||||
|
"DDRNet vegetation subtypes remain prediction-only and are not planner authority.",
|
||||||
|
"This full-video pass does not add full-route TGS, ditch or negative-obstacle proof.",
|
||||||
|
"People and vehicles still require an independent fail-safe detector and STOP path.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
authority = {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
"actuation_accepted": False,
|
||||||
|
"camera_semantics_can_clear_rigid_geometry": False,
|
||||||
|
}
|
||||||
|
identity = {
|
||||||
|
"lab_id": "lab-v1-vegetation-mission-policy",
|
||||||
|
"base_result_id": base["result_id"],
|
||||||
|
"selected_candidate": base_identity["selected_candidate"],
|
||||||
|
"candidate_metrics": base_identity["candidate_metrics"],
|
||||||
|
"source": {
|
||||||
|
"shadow_session": FULL_ROUTE_SOURCE_ID,
|
||||||
|
"shadow_camera": job.source_id,
|
||||||
|
"shadow_frame_count": FULL_ROUTE_FRAME_COUNT,
|
||||||
|
"video_shadow_frame_count": FULL_ROUTE_FRAME_COUNT,
|
||||||
|
},
|
||||||
|
"route_full_review": full_route,
|
||||||
|
"authority": authority,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||||
|
result_id = f"{RESULT_PREFIX}{identity_sha256}"
|
||||||
|
manifest = {
|
||||||
|
"schema_version": LAB_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"created_at_utc": datetime.now(UTC).isoformat(),
|
||||||
|
"ground_truth": False,
|
||||||
|
"status": "visual-shadow-ready-policy-not-authorized",
|
||||||
|
"identity": identity,
|
||||||
|
"source": identity["source"],
|
||||||
|
"route_video": None,
|
||||||
|
"route_review": None,
|
||||||
|
"route_full_review": full_route,
|
||||||
|
"method": {
|
||||||
|
"completeness": "complete",
|
||||||
|
"execution_class": "ai-inference",
|
||||||
|
"pipeline_id": "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||||
|
},
|
||||||
|
"metrics": {"candidates": base["metrics"]["candidates"]},
|
||||||
|
"decision": {
|
||||||
|
"selected_candidate": base_identity["selected_candidate"],
|
||||||
|
"visual_shadow_ready": True,
|
||||||
|
"full_video_shadow_ready": True,
|
||||||
|
"mission_policy_ready_for_configuration": True,
|
||||||
|
"multilayer_policy_review_ready": True,
|
||||||
|
"navigation_accepted": False,
|
||||||
|
"production_accepted": False,
|
||||||
|
},
|
||||||
|
"limitations": full_route["limitations"],
|
||||||
|
"authority": authority,
|
||||||
|
"catalogs": {"goose": [], "ravnoves": []},
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
(temporary / "result.json").write_bytes(canonical_json(manifest) + b"\n")
|
||||||
|
destination = output_root / result_id
|
||||||
|
if destination.exists():
|
||||||
|
raise VegetationShadowLabError("immutable full-route LAB result already exists")
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
return destination
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(temporary, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--base-lab-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--review-pack-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--eomt-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--ddrnet-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--tgs-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--output-root", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
print(
|
||||||
|
seal_mixed_route_vegetation_review(
|
||||||
|
base_lab_root=args.base_lab_root,
|
||||||
|
review_pack_root=args.review_pack_root,
|
||||||
|
eomt_root=args.eomt_root,
|
||||||
|
ddrnet_root=args.ddrnet_root,
|
||||||
|
tgs_root=args.tgs_root,
|
||||||
|
output_root=args.output_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
"""Canonical recorded-LAB spatial adapter for sealed Rerun recordings.
|
||||||
|
|
||||||
|
The LAB viewer must not run an independent Rerun transport beside the camera
|
||||||
|
transport. This adapter reads the immutable recording once, indexes the
|
||||||
|
recorded source cloud and sensor pose, estimates the session sensor height from
|
||||||
|
the initial stationary cloud, and returns both the current increment and a
|
||||||
|
bounded accumulated local-SLAM cloud in a ground-rebased body frame. Camera,
|
||||||
|
spatial layers and the common timeline can therefore be driven by one media
|
||||||
|
clock without a per-LAB coordinate adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from bisect import bisect_right
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rerun_bindings as rr_bindings
|
||||||
|
|
||||||
|
CANONICAL_LAB_SPATIAL_PROFILE: Final = "source-paced-ground-v3"
|
||||||
|
_POINT_ENTITY: Final = "/world/points"
|
||||||
|
_POSE_ENTITY: Final = "/world/sensor_pose"
|
||||||
|
_TRAJECTORY_ENTITY: Final = "/world/trajectory"
|
||||||
|
_POINT_COMPONENT: Final = "Points3D:positions"
|
||||||
|
_POSE_TRANSLATION_COMPONENT: Final = "Transform3D:translation"
|
||||||
|
_POSE_QUATERNION_COMPONENT: Final = "Transform3D:quaternion"
|
||||||
|
_TRAJECTORY_COMPONENT: Final = "LineStrips3D:strips"
|
||||||
|
_INDEX_LOCK: Final = Lock()
|
||||||
|
_HEIGHT_CALIBRATION_SECONDS: Final = 60.0
|
||||||
|
_HEIGHT_CALIBRATION_MAX_FRAMES: Final = 120
|
||||||
|
_HEIGHT_NEAR_MIN_RADIUS_M: Final = 1.0
|
||||||
|
_HEIGHT_NEAR_MAX_RADIUS_M: Final = 6.0
|
||||||
|
_HEIGHT_LOWER_QUANTILE: Final = 0.025
|
||||||
|
_LOCAL_HEIGHT_QUANTILE: Final = 0.10
|
||||||
|
_LOCAL_HEIGHT_HALF_WINDOW_SECONDS: Final = 1.0
|
||||||
|
_LOCAL_SLAM_HISTORY_SECONDS: Final = 5.0
|
||||||
|
_LOCAL_SLAM_RADIUS_M: Final = 30.0
|
||||||
|
_LOCAL_SLAM_VERTICAL_LIMIT_M: Final = 6.0
|
||||||
|
_LOCAL_SLAM_VOXEL_SIZE_M: Final = 0.12
|
||||||
|
_LOCAL_SLAM_POINT_LIMIT: Final = 27_000
|
||||||
|
_FORWARD_HALF_WINDOW_SECONDS: Final = 1.0
|
||||||
|
_FORWARD_MINIMUM_DISPLACEMENT_M: Final = 0.15
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _TimedPoints:
|
||||||
|
times_ns: tuple[int, ...]
|
||||||
|
values: tuple[np.ndarray, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _TimedPoses:
|
||||||
|
times_ns: tuple[int, ...]
|
||||||
|
translations: tuple[np.ndarray, ...]
|
||||||
|
quaternions_xyzw: tuple[np.ndarray, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _CanonicalSpatialIndex:
|
||||||
|
points: _TimedPoints
|
||||||
|
poses: _TimedPoses
|
||||||
|
trajectories: _TimedPoints
|
||||||
|
sensor_height_m: float
|
||||||
|
sensor_height_sample_count: int
|
||||||
|
sensor_height_mad_m: float
|
||||||
|
|
||||||
|
|
||||||
|
def _session_times(batch: Any) -> Any | None:
|
||||||
|
if "session_time" not in batch.schema.names:
|
||||||
|
return None
|
||||||
|
return batch.column("session_time")
|
||||||
|
|
||||||
|
|
||||||
|
def _point_rows(
|
||||||
|
chunks: list[Any],
|
||||||
|
entity: str,
|
||||||
|
component: str,
|
||||||
|
*,
|
||||||
|
nested: bool = False,
|
||||||
|
) -> _TimedPoints:
|
||||||
|
rows: list[tuple[int, np.ndarray]] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
if chunk.entity_path != entity:
|
||||||
|
continue
|
||||||
|
batch = chunk.to_record_batch()
|
||||||
|
times = _session_times(batch)
|
||||||
|
if times is None or component not in batch.schema.names:
|
||||||
|
continue
|
||||||
|
column = batch.column(component)
|
||||||
|
for row_index in range(batch.num_rows):
|
||||||
|
timestamp = int(times[row_index].value)
|
||||||
|
payload = column[row_index].as_py()
|
||||||
|
if nested:
|
||||||
|
payload = payload[0] if payload else []
|
||||||
|
values = np.asarray(payload, dtype=np.float32)
|
||||||
|
if values.ndim != 2 or values.shape[1] != 3 or not np.isfinite(values).all():
|
||||||
|
continue
|
||||||
|
values.setflags(write=False)
|
||||||
|
rows.append((timestamp, values))
|
||||||
|
rows.sort(key=lambda item: item[0])
|
||||||
|
return _TimedPoints(
|
||||||
|
times_ns=tuple(timestamp for timestamp, _ in rows),
|
||||||
|
values=tuple(values for _, values in rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pose_rows(chunks: list[Any]) -> _TimedPoses:
|
||||||
|
rows: list[tuple[int, np.ndarray, np.ndarray]] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
if chunk.entity_path != _POSE_ENTITY:
|
||||||
|
continue
|
||||||
|
batch = chunk.to_record_batch()
|
||||||
|
times = _session_times(batch)
|
||||||
|
if (
|
||||||
|
times is None
|
||||||
|
or _POSE_TRANSLATION_COMPONENT not in batch.schema.names
|
||||||
|
or _POSE_QUATERNION_COMPONENT not in batch.schema.names
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
translations = batch.column(_POSE_TRANSLATION_COMPONENT)
|
||||||
|
quaternions = batch.column(_POSE_QUATERNION_COMPONENT)
|
||||||
|
for row_index in range(batch.num_rows):
|
||||||
|
translation_values = translations[row_index].as_py()
|
||||||
|
quaternion_values = quaternions[row_index].as_py()
|
||||||
|
if len(translation_values) != 1 or len(quaternion_values) != 1:
|
||||||
|
continue
|
||||||
|
translation = np.asarray(translation_values[0], dtype=np.float64)
|
||||||
|
quaternion = np.asarray(quaternion_values[0], dtype=np.float64)
|
||||||
|
if (
|
||||||
|
translation.shape != (3,)
|
||||||
|
or quaternion.shape != (4,)
|
||||||
|
or not np.isfinite(translation).all()
|
||||||
|
or not np.isfinite(quaternion).all()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
norm = float(np.linalg.norm(quaternion))
|
||||||
|
if norm <= 1e-9:
|
||||||
|
continue
|
||||||
|
translation.setflags(write=False)
|
||||||
|
normalized = quaternion / norm
|
||||||
|
normalized.setflags(write=False)
|
||||||
|
rows.append((int(times[row_index].value), translation, normalized))
|
||||||
|
rows.sort(key=lambda item: item[0])
|
||||||
|
return _TimedPoses(
|
||||||
|
times_ns=tuple(timestamp for timestamp, _, _ in rows),
|
||||||
|
translations=tuple(translation for _, translation, _ in rows),
|
||||||
|
quaternions_xyzw=tuple(quaternion for _, _, quaternion in rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load_index_cached(
|
||||||
|
path_text: str,
|
||||||
|
byte_length: int,
|
||||||
|
modified_ns: int,
|
||||||
|
generation_sha256: str,
|
||||||
|
) -> _CanonicalSpatialIndex:
|
||||||
|
path = Path(path_text)
|
||||||
|
stat = path.stat()
|
||||||
|
if stat.st_size != byte_length or stat.st_mtime_ns != modified_ns:
|
||||||
|
raise ValueError("Recorded LAB source changed during spatial indexing")
|
||||||
|
if len(generation_sha256) != 64:
|
||||||
|
raise ValueError("Recorded LAB generation is invalid")
|
||||||
|
# Decode only the three canonical entities in one pass. Building a lazy
|
||||||
|
# store first decodes the complete RRD (including unrelated payloads), and
|
||||||
|
# then scanning that store once per layer made first-open take more than a
|
||||||
|
# minute on RAVNOVES004TREE.
|
||||||
|
chunks = (
|
||||||
|
rr_bindings.RrdReaderInternal(str(path))
|
||||||
|
.stream()
|
||||||
|
.filter(content=[_POINT_ENTITY, _POSE_ENTITY, _TRAJECTORY_ENTITY])
|
||||||
|
.to_chunks()
|
||||||
|
)
|
||||||
|
points = _point_rows(chunks, _POINT_ENTITY, _POINT_COMPONENT)
|
||||||
|
poses = _pose_rows(chunks)
|
||||||
|
trajectories = _point_rows(
|
||||||
|
chunks,
|
||||||
|
_TRAJECTORY_ENTITY,
|
||||||
|
_TRAJECTORY_COMPONENT,
|
||||||
|
nested=True,
|
||||||
|
)
|
||||||
|
if not points.times_ns or not poses.times_ns or not trajectories.times_ns:
|
||||||
|
raise ValueError("Recorded LAB source has no canonical spatial layers")
|
||||||
|
sensor_height_m, sensor_height_sample_count, sensor_height_mad_m = (
|
||||||
|
_estimate_sensor_height(points, poses)
|
||||||
|
)
|
||||||
|
return _CanonicalSpatialIndex(
|
||||||
|
points=points,
|
||||||
|
poses=poses,
|
||||||
|
trajectories=trajectories,
|
||||||
|
sensor_height_m=sensor_height_m,
|
||||||
|
sensor_height_sample_count=sensor_height_sample_count,
|
||||||
|
sensor_height_mad_m=sensor_height_mad_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_index(
|
||||||
|
path_text: str,
|
||||||
|
byte_length: int,
|
||||||
|
modified_ns: int,
|
||||||
|
generation_sha256: str,
|
||||||
|
) -> _CanonicalSpatialIndex:
|
||||||
|
# functools.lru_cache is coherent but intentionally releases its lock
|
||||||
|
# during a miss. Serialize cold RRD indexing so simultaneous camera/TGS
|
||||||
|
# admission cannot parse the same 80 MiB recording twice.
|
||||||
|
with _INDEX_LOCK:
|
||||||
|
return _load_index_cached(
|
||||||
|
path_text,
|
||||||
|
byte_length,
|
||||||
|
modified_ns,
|
||||||
|
generation_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_index(times_ns: tuple[int, ...], target_ns: int) -> int:
|
||||||
|
return max(0, min(len(times_ns) - 1, bisect_right(times_ns, target_ns) - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def _rotation_map_from_body(quaternion_xyzw: np.ndarray) -> np.ndarray:
|
||||||
|
x, y, z, w = (float(value) for value in quaternion_xyzw)
|
||||||
|
return np.asarray(
|
||||||
|
[
|
||||||
|
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||||
|
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||||
|
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_points_to_body(
|
||||||
|
points_map: np.ndarray,
|
||||||
|
translation_map: np.ndarray,
|
||||||
|
quaternion_xyzw: np.ndarray,
|
||||||
|
) -> np.ndarray:
|
||||||
|
rotation = _rotation_map_from_body(quaternion_xyzw)
|
||||||
|
# Row vectors: inverse(map_from_body) == right-multiply by map_from_body.
|
||||||
|
body = (points_map.astype(np.float64) - translation_map) @ rotation
|
||||||
|
return body.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _gravity_stable_basis_map_from_body(
|
||||||
|
poses: _TimedPoses,
|
||||||
|
target_time_ns: int,
|
||||||
|
) -> tuple[np.ndarray, str]:
|
||||||
|
"""Return a right-handed forward/left/up base frame in the RFU map.
|
||||||
|
|
||||||
|
Rerun declares this recording map as RFU, while the metric LAB scene
|
||||||
|
consumes points as forward/left/up. The LiDAR quaternion columns are sensor
|
||||||
|
right/forward/up and also contain rover or handheld roll/pitch, so they are
|
||||||
|
not a body basis. Route displacement owns yaw when available; the sensor's
|
||||||
|
local +Y (Rerun Forward) projected onto map gravity is the stationary
|
||||||
|
fallback. Map +Z always owns up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
center = _latest_index(poses.times_ns, target_time_ns)
|
||||||
|
half_window_ns = round(_FORWARD_HALF_WINDOW_SECONDS * 1_000_000_000)
|
||||||
|
first = _latest_index(poses.times_ns, max(0, target_time_ns - half_window_ns))
|
||||||
|
last = min(
|
||||||
|
len(poses.times_ns) - 1,
|
||||||
|
max(0, bisect_right(poses.times_ns, target_time_ns + half_window_ns) - 1),
|
||||||
|
)
|
||||||
|
route = poses.translations[last] - poses.translations[first]
|
||||||
|
route_xy = np.asarray([route[0], route[1], 0.0], dtype=np.float64)
|
||||||
|
route_norm = float(np.linalg.norm(route_xy))
|
||||||
|
|
||||||
|
sensor_rotation = _rotation_map_from_body(poses.quaternions_xyzw[center])
|
||||||
|
sensor_forward = np.asarray(
|
||||||
|
[sensor_rotation[0, 1], sensor_rotation[1, 1], 0.0],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
sensor_forward_norm = float(np.linalg.norm(sensor_forward))
|
||||||
|
if sensor_forward_norm <= 1e-9:
|
||||||
|
raise ValueError("Recorded LAB sensor forward axis is invalid")
|
||||||
|
sensor_forward /= sensor_forward_norm
|
||||||
|
|
||||||
|
if route_norm >= _FORWARD_MINIMUM_DISPLACEMENT_M:
|
||||||
|
forward = route_xy / route_norm
|
||||||
|
if float(np.dot(forward, sensor_forward)) < 0.0:
|
||||||
|
forward = -forward
|
||||||
|
forward_source = "smoothed-pose-trajectory-tangent"
|
||||||
|
else:
|
||||||
|
forward = sensor_forward
|
||||||
|
forward_source = "rerun-rfu-sensor-forward-fallback"
|
||||||
|
|
||||||
|
up = np.asarray([0.0, 0.0, 1.0], dtype=np.float64)
|
||||||
|
left = np.cross(up, forward)
|
||||||
|
left_norm = float(np.linalg.norm(left))
|
||||||
|
if left_norm <= 1e-9:
|
||||||
|
raise ValueError("Recorded LAB body left axis is invalid")
|
||||||
|
left /= left_norm
|
||||||
|
forward = np.cross(left, up)
|
||||||
|
forward /= float(np.linalg.norm(forward))
|
||||||
|
basis = np.column_stack((forward, left, up))
|
||||||
|
if (
|
||||||
|
not np.allclose(basis.T @ basis, np.eye(3), atol=1e-7)
|
||||||
|
or np.linalg.det(basis) < 0.999999
|
||||||
|
):
|
||||||
|
raise ValueError("Recorded LAB gravity-stable body basis is invalid")
|
||||||
|
return basis, forward_source
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_sensor_height(points: _TimedPoints, poses: _TimedPoses) -> tuple[float, int, float]:
|
||||||
|
"""Estimate one session mount height from the initial qualified cloud.
|
||||||
|
|
||||||
|
The K1 recording has no explicit physical mount-height entity. The initial
|
||||||
|
stationary minute is therefore the only admissible automatic calibration
|
||||||
|
source. A low near-field quantile is measured per source increment and the
|
||||||
|
session median rejects vegetation/ravine outliers. The result stays
|
||||||
|
diagnostic and is never promoted to navigation authority by this adapter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
first_time_ns = points.times_ns[0]
|
||||||
|
calibration_end_ns = first_time_ns + round(_HEIGHT_CALIBRATION_SECONDS * 1_000_000_000)
|
||||||
|
candidates = [
|
||||||
|
index
|
||||||
|
for index, timestamp in enumerate(points.times_ns)
|
||||||
|
if timestamp <= calibration_end_ns
|
||||||
|
][:_HEIGHT_CALIBRATION_MAX_FRAMES]
|
||||||
|
estimates: list[float] = []
|
||||||
|
for point_index in candidates:
|
||||||
|
pose_index = _latest_index(poses.times_ns, points.times_ns[point_index])
|
||||||
|
delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index]
|
||||||
|
radius = np.linalg.norm(delta[:, :2], axis=1)
|
||||||
|
eligible = delta[
|
||||||
|
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
||||||
|
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
||||||
|
& (delta[:, 2] >= -2.0)
|
||||||
|
& (delta[:, 2] <= 0.5)
|
||||||
|
]
|
||||||
|
if eligible.shape[0] < 100:
|
||||||
|
continue
|
||||||
|
estimate = -float(np.quantile(eligible[:, 2], _HEIGHT_LOWER_QUANTILE))
|
||||||
|
if 0.08 <= estimate <= 2.5:
|
||||||
|
estimates.append(estimate)
|
||||||
|
if len(estimates) < 8:
|
||||||
|
raise ValueError("Recorded LAB sensor height cannot be estimated from source cloud")
|
||||||
|
values = np.asarray(estimates, dtype=np.float64)
|
||||||
|
height = float(np.median(values))
|
||||||
|
mad = float(np.median(np.abs(values - height)))
|
||||||
|
return height, len(estimates), mad
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_local_sensor_height(
|
||||||
|
points: _TimedPoints,
|
||||||
|
poses: _TimedPoses,
|
||||||
|
target_time_ns: int,
|
||||||
|
fallback_height_m: float,
|
||||||
|
) -> tuple[float, int, float, str]:
|
||||||
|
"""Estimate the current gravity-axis height without a fixed camera mount.
|
||||||
|
|
||||||
|
RAVNOVES004TREE changes sensor height during the route. A session-wide
|
||||||
|
constant therefore moves the scene vertically whenever the operator raises
|
||||||
|
or lowers K1. Use a short source-time window and a conservative near-field
|
||||||
|
ground quantile; fall back to the sealed session calibration only when the
|
||||||
|
current cloud has insufficient support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
half_window_ns = round(_LOCAL_HEIGHT_HALF_WINDOW_SECONDS * 1_000_000_000)
|
||||||
|
first = bisect_right(points.times_ns, max(0, target_time_ns - half_window_ns) - 1)
|
||||||
|
last = bisect_right(points.times_ns, target_time_ns + half_window_ns)
|
||||||
|
estimates: list[float] = []
|
||||||
|
for point_index in range(first, last):
|
||||||
|
pose_index = _latest_index(poses.times_ns, points.times_ns[point_index])
|
||||||
|
delta = points.values[point_index].astype(np.float64) - poses.translations[pose_index]
|
||||||
|
radius = np.linalg.norm(delta[:, :2], axis=1)
|
||||||
|
eligible = delta[
|
||||||
|
(radius >= _HEIGHT_NEAR_MIN_RADIUS_M)
|
||||||
|
& (radius <= _HEIGHT_NEAR_MAX_RADIUS_M)
|
||||||
|
& (delta[:, 2] >= -2.5)
|
||||||
|
& (delta[:, 2] <= 0.5)
|
||||||
|
]
|
||||||
|
if eligible.shape[0] < 100:
|
||||||
|
continue
|
||||||
|
estimate = -float(np.quantile(eligible[:, 2], _LOCAL_HEIGHT_QUANTILE))
|
||||||
|
if 0.03 <= estimate <= 2.5:
|
||||||
|
estimates.append(estimate)
|
||||||
|
if not estimates:
|
||||||
|
return fallback_height_m, 0, 0.0, "session-source-cloud-fallback"
|
||||||
|
values = np.asarray(estimates, dtype=np.float64)
|
||||||
|
height = float(np.median(values))
|
||||||
|
mad = float(np.median(np.abs(values - height)))
|
||||||
|
return height, len(estimates), mad, "local-source-cloud-ground-quantile-median"
|
||||||
|
|
||||||
|
|
||||||
|
def _ground_origin_map(
|
||||||
|
sensor_origin_map: np.ndarray,
|
||||||
|
sensor_height_m: float,
|
||||||
|
) -> np.ndarray:
|
||||||
|
# The calibrated height belongs to the map gravity axis. Sensor roll/pitch
|
||||||
|
# must never tilt the ground origin or the accumulated world cloud.
|
||||||
|
return sensor_origin_map - np.asarray([0.0, 0.0, sensor_height_m])
|
||||||
|
|
||||||
|
|
||||||
|
def _map_points_to_ground_body(
|
||||||
|
points_map: np.ndarray,
|
||||||
|
ground_origin_map: np.ndarray,
|
||||||
|
basis_map_from_body: np.ndarray,
|
||||||
|
) -> np.ndarray:
|
||||||
|
body = (points_map.astype(np.float64) - ground_origin_map) @ basis_map_from_body
|
||||||
|
return body.astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_local_slam(
|
||||||
|
points: _TimedPoints,
|
||||||
|
target_time_ns: int,
|
||||||
|
ground_origin_map: np.ndarray,
|
||||||
|
basis_map_from_body: np.ndarray,
|
||||||
|
) -> tuple[np.ndarray, int, int]:
|
||||||
|
start_ns = target_time_ns - round(_LOCAL_SLAM_HISTORY_SECONDS * 1_000_000_000)
|
||||||
|
first = bisect_right(points.times_ns, start_ns - 1)
|
||||||
|
last = bisect_right(points.times_ns, target_time_ns)
|
||||||
|
selected = points.values[first:last]
|
||||||
|
if not selected:
|
||||||
|
return np.empty((0, 3), dtype=np.float32), 0, 0
|
||||||
|
source_count = sum(int(value.shape[0]) for value in selected)
|
||||||
|
local = _map_points_to_ground_body(
|
||||||
|
np.concatenate(selected, axis=0),
|
||||||
|
ground_origin_map,
|
||||||
|
basis_map_from_body,
|
||||||
|
)
|
||||||
|
mask = (
|
||||||
|
(np.linalg.norm(local[:, :2], axis=1) <= _LOCAL_SLAM_RADIUS_M)
|
||||||
|
& (np.abs(local[:, 2]) <= _LOCAL_SLAM_VERTICAL_LIMIT_M)
|
||||||
|
)
|
||||||
|
local = local[mask]
|
||||||
|
if local.shape[0] == 0:
|
||||||
|
return local, len(selected), source_count
|
||||||
|
voxel = np.floor(local / _LOCAL_SLAM_VOXEL_SIZE_M).astype(np.int32)
|
||||||
|
_, retained = np.unique(voxel, axis=0, return_index=True)
|
||||||
|
local = local[np.sort(retained)]
|
||||||
|
if local.shape[0] > _LOCAL_SLAM_POINT_LIMIT:
|
||||||
|
stride = int(np.ceil(local.shape[0] / _LOCAL_SLAM_POINT_LIMIT))
|
||||||
|
local = local[::stride][:_LOCAL_SLAM_POINT_LIMIT]
|
||||||
|
return np.ascontiguousarray(local, dtype=np.float32), len(selected), source_count
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_lab_spatial_frame_from_index(
|
||||||
|
index: _CanonicalSpatialIndex,
|
||||||
|
target_time_ns: int,
|
||||||
|
*,
|
||||||
|
include_local_slam: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||||
|
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
|
||||||
|
trajectory_index = _latest_index(index.trajectories.times_ns, target_time_ns)
|
||||||
|
translation = index.poses.translations[pose_index]
|
||||||
|
sensor_height_m, sensor_height_sample_count, sensor_height_mad_m, height_source = (
|
||||||
|
_estimate_local_sensor_height(
|
||||||
|
index.points,
|
||||||
|
index.poses,
|
||||||
|
index.points.times_ns[point_index],
|
||||||
|
index.sensor_height_m,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
basis_map_from_body, forward_source = _gravity_stable_basis_map_from_body(
|
||||||
|
index.poses,
|
||||||
|
index.points.times_ns[point_index],
|
||||||
|
)
|
||||||
|
ground_origin = _ground_origin_map(
|
||||||
|
translation,
|
||||||
|
sensor_height_m,
|
||||||
|
)
|
||||||
|
points_body = _map_points_to_ground_body(
|
||||||
|
index.points.values[point_index],
|
||||||
|
ground_origin,
|
||||||
|
basis_map_from_body,
|
||||||
|
)
|
||||||
|
if include_local_slam:
|
||||||
|
local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam(
|
||||||
|
index.points,
|
||||||
|
index.points.times_ns[point_index],
|
||||||
|
ground_origin,
|
||||||
|
basis_map_from_body,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
local_slam = np.empty((0, 3), dtype=np.float32)
|
||||||
|
local_slam_source_frames = 0
|
||||||
|
local_slam_source_points = 0
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
|
"target_time_ns": target_time_ns,
|
||||||
|
"source_time_ns": index.points.times_ns[point_index],
|
||||||
|
"pose_time_ns": index.poses.times_ns[pose_index],
|
||||||
|
"trajectory_time_ns": index.trajectories.times_ns[trajectory_index],
|
||||||
|
"coordinate_frame": "body-ground",
|
||||||
|
"sensor_height": {
|
||||||
|
"meters": sensor_height_m,
|
||||||
|
"source": height_source,
|
||||||
|
"sample_count": sensor_height_sample_count,
|
||||||
|
"mad_m": sensor_height_mad_m,
|
||||||
|
"session_fallback_meters": index.sensor_height_m,
|
||||||
|
"authority": "visual-derived",
|
||||||
|
},
|
||||||
|
"spatial_profile": {
|
||||||
|
"profile_id": CANONICAL_LAB_SPATIAL_PROFILE,
|
||||||
|
"local_slam_history_seconds": _LOCAL_SLAM_HISTORY_SECONDS,
|
||||||
|
"local_slam_radius_m": _LOCAL_SLAM_RADIUS_M,
|
||||||
|
"local_slam_voxel_size_m": _LOCAL_SLAM_VOXEL_SIZE_M,
|
||||||
|
"local_slam_point_limit": _LOCAL_SLAM_POINT_LIMIT,
|
||||||
|
},
|
||||||
|
"body_frame": {
|
||||||
|
"origin_map_xyz_m": ground_origin.tolist(),
|
||||||
|
"sensor_origin_map_xyz_m": translation.tolist(),
|
||||||
|
"basis_map_from_body": basis_map_from_body.tolist(),
|
||||||
|
"up_source": "rerun-rfu-map-gravity-axis",
|
||||||
|
"forward_source": forward_source,
|
||||||
|
},
|
||||||
|
"source_point_count": int(points_body.shape[0]),
|
||||||
|
"source_points_body_xyz_m": points_body.tolist(),
|
||||||
|
"local_slam_source_frame_count": local_slam_source_frames,
|
||||||
|
"local_slam_source_point_count": local_slam_source_points,
|
||||||
|
"local_slam_point_count": int(local_slam.shape[0]),
|
||||||
|
"local_slam_body_xyz_m": local_slam.tolist(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_lab_spatial_frame(
|
||||||
|
recording_path: Path,
|
||||||
|
generation_sha256: str,
|
||||||
|
target_time_ns: int,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return the current source cloud and bounded Local SLAM on one media time."""
|
||||||
|
|
||||||
|
if target_time_ns < 0:
|
||||||
|
raise ValueError("Recorded LAB target time is invalid")
|
||||||
|
stat = recording_path.stat()
|
||||||
|
index = _load_index(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
)
|
||||||
|
return _canonical_lab_spatial_frame_from_index(index, target_time_ns)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_lab_spatial_timeline_samples(
|
||||||
|
recording_path: Path,
|
||||||
|
generation_sha256: str,
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
start_sequence: int,
|
||||||
|
frame_count: int,
|
||||||
|
*,
|
||||||
|
include_local_slam: bool = True,
|
||||||
|
) -> tuple[dict[str, object] | None, ...]:
|
||||||
|
"""Project only new source increments onto a denser camera timeline.
|
||||||
|
|
||||||
|
Camera is roughly 10 Hz in RAVNOVES004TREE while the sealed source cloud is
|
||||||
|
roughly 2 Hz. Returning the same JSON point array for every camera frame
|
||||||
|
multiplies transfer and parse cost and makes the viewer chase itself. A row
|
||||||
|
is populated only when its nearest causal source increment changes; the
|
||||||
|
canonical viewer retains that spatial frame until the next increment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if (
|
||||||
|
start_sequence < 0
|
||||||
|
or frame_count < 1
|
||||||
|
or start_sequence >= len(frame_times_ns)
|
||||||
|
or any(
|
||||||
|
current <= previous
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("Recorded LAB timeline sample request is invalid")
|
||||||
|
stat = recording_path.stat()
|
||||||
|
index = _load_index(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
)
|
||||||
|
stop = min(len(frame_times_ns), start_sequence + frame_count)
|
||||||
|
samples: list[dict[str, object] | None] = []
|
||||||
|
for sequence in range(start_sequence, stop):
|
||||||
|
target_time_ns = frame_times_ns[sequence]
|
||||||
|
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||||
|
previous_point_index = (
|
||||||
|
-1
|
||||||
|
if sequence == 0
|
||||||
|
else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1])
|
||||||
|
)
|
||||||
|
samples.append(
|
||||||
|
_canonical_lab_spatial_frame_from_index(
|
||||||
|
index,
|
||||||
|
target_time_ns,
|
||||||
|
include_local_slam=include_local_slam,
|
||||||
|
)
|
||||||
|
if point_index != previous_point_index
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return tuple(samples)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=2)
|
||||||
|
def _canonical_lab_spatial_playback_points_cached(
|
||||||
|
recording_path_text: str,
|
||||||
|
recording_size: int,
|
||||||
|
recording_mtime_ns: int,
|
||||||
|
generation_sha256: str,
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
) -> tuple[np.ndarray, tuple[int, ...]]:
|
||||||
|
del recording_size, recording_mtime_ns
|
||||||
|
recording_path = Path(recording_path_text)
|
||||||
|
stat = recording_path.stat()
|
||||||
|
index = _load_index(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
)
|
||||||
|
increments: list[np.ndarray] = []
|
||||||
|
offsets = [0]
|
||||||
|
point_count = 0
|
||||||
|
previous_point_index = -1
|
||||||
|
for target_time_ns in frame_times_ns:
|
||||||
|
point_index = _latest_index(index.points.times_ns, target_time_ns)
|
||||||
|
if point_index != previous_point_index:
|
||||||
|
increment = np.ascontiguousarray(index.points.values[point_index], dtype="<f4")
|
||||||
|
increments.append(increment)
|
||||||
|
point_count += int(increment.shape[0])
|
||||||
|
offsets.append(point_count)
|
||||||
|
previous_point_index = point_index
|
||||||
|
points = (
|
||||||
|
np.ascontiguousarray(np.concatenate(increments, axis=0), dtype="<f4")
|
||||||
|
if increments
|
||||||
|
else np.empty((0, 3), dtype="<f4")
|
||||||
|
)
|
||||||
|
points.setflags(write=False)
|
||||||
|
return points, tuple(offsets)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_lab_spatial_playback_points(
|
||||||
|
recording_path: Path,
|
||||||
|
generation_sha256: str,
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
) -> tuple[np.ndarray, tuple[int, ...]]:
|
||||||
|
"""Return one retained map-coordinate point track for a camera timeline."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not frame_times_ns
|
||||||
|
or any(
|
||||||
|
current <= previous
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("Recorded LAB playback timeline is invalid")
|
||||||
|
stat = recording_path.stat()
|
||||||
|
return _canonical_lab_spatial_playback_points_cached(
|
||||||
|
str(recording_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
generation_sha256,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
@@ -36,6 +36,7 @@ IMAGE_DIGEST_PATTERN: Final = re.compile(r"^sha256:[a-f0-9]{64}$")
|
|||||||
DEFAULT_CHUNK_BYTES: Final = 8 * 1024 * 1024
|
DEFAULT_CHUNK_BYTES: Final = 8 * 1024 * 1024
|
||||||
MAX_JSON_RESPONSE_BYTES: Final = 32 * 1024 * 1024
|
MAX_JSON_RESPONSE_BYTES: Final = 32 * 1024 * 1024
|
||||||
MAX_RETRIES: Final = 3
|
MAX_RETRIES: Final = 3
|
||||||
|
RETRYABLE_PROVIDER_STATUS_CODES: Final = {502, 503, 504}
|
||||||
DEFAULT_INGEST_TIMEOUT_SECONDS: Final = 30 * 60.0
|
DEFAULT_INGEST_TIMEOUT_SECONDS: Final = 30 * 60.0
|
||||||
|
|
||||||
|
|
||||||
@@ -899,7 +900,10 @@ def _validate_runtime_provenance(document: Mapping[str, object]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _unavailable(message: str, error: httpx.HTTPError) -> GaussianPipelineGatewayError:
|
def _unavailable(message: str, error: httpx.HTTPError) -> GaussianPipelineGatewayError:
|
||||||
if isinstance(error, httpx.TransportError):
|
if isinstance(error, httpx.TransportError) or (
|
||||||
|
isinstance(error, httpx.HTTPStatusError)
|
||||||
|
and error.response.status_code in RETRYABLE_PROVIDER_STATUS_CODES
|
||||||
|
):
|
||||||
return GaussianPipelineUnavailableError(message)
|
return GaussianPipelineUnavailableError(message)
|
||||||
return GaussianPipelineGatewayError(message)
|
return GaussianPipelineGatewayError(message)
|
||||||
|
|
||||||
@@ -922,4 +926,6 @@ def _provider_rejection(response: httpx.Response) -> GaussianPipelineGatewayErro
|
|||||||
message = f"Gaussian provider rejected request (HTTP {response.status_code})"
|
message = f"Gaussian provider rejected request (HTTP {response.status_code})"
|
||||||
if detail is not None:
|
if detail is not None:
|
||||||
message = f"{message}: {detail}"
|
message = f"{message}: {detail}"
|
||||||
|
if response.status_code in RETRYABLE_PROVIDER_STATUS_CODES:
|
||||||
|
return GaussianPipelineUnavailableError(message)
|
||||||
return GaussianPipelineGatewayError(message)
|
return GaussianPipelineGatewayError(message)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from collections import deque
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Final, TypeVar
|
from typing import Any, Final
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ PROVIDER_JOB_STATES: Final = {
|
|||||||
}
|
}
|
||||||
PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60
|
PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60
|
||||||
PROVIDER_UNAVAILABLE_RETRY_LIMIT: Final = 6
|
PROVIDER_UNAVAILABLE_RETRY_LIMIT: Final = 6
|
||||||
_T = TypeVar("_T")
|
IMPORT_DISK_RESERVE_BYTES: Final = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class SimulationProjectError(RuntimeError):
|
class SimulationProjectError(RuntimeError):
|
||||||
@@ -701,6 +701,7 @@ class SimulationProjectService:
|
|||||||
self._raise_if_cancelled(project_id)
|
self._raise_if_cancelled(project_id)
|
||||||
artifacts = _artifact_descriptors(result.get("artifacts"))
|
artifacts = _artifact_descriptors(result.get("artifacts"))
|
||||||
artifacts_root = self.store.artifacts_root(project_id)
|
artifacts_root = self.store.artifacts_root(project_id)
|
||||||
|
self._ensure_import_capacity(project_id, artifacts, artifacts_root)
|
||||||
for descriptor in artifacts:
|
for descriptor in artifacts:
|
||||||
self._raise_if_cancelled(project_id)
|
self._raise_if_cancelled(project_id)
|
||||||
_retry_provider_unavailable(
|
_retry_provider_unavailable(
|
||||||
@@ -722,6 +723,8 @@ class SimulationProjectService:
|
|||||||
)
|
)
|
||||||
except _SimulationProcessingCancelled:
|
except _SimulationProcessingCancelled:
|
||||||
pass
|
pass
|
||||||
|
except GaussianPipelineUnavailableError:
|
||||||
|
self._requeue_provider_unavailable(project_id)
|
||||||
except (GaussianPipelineGatewayError, SimulationProjectError, OSError) as exc:
|
except (GaussianPipelineGatewayError, SimulationProjectError, OSError) as exc:
|
||||||
with suppress(SimulationProjectError):
|
with suppress(SimulationProjectError):
|
||||||
self.store.fail(project_id, str(exc))
|
self.store.fail(project_id, str(exc))
|
||||||
@@ -731,6 +734,70 @@ class SimulationProjectService:
|
|||||||
if provider is not None:
|
if provider is not None:
|
||||||
provider.close()
|
provider.close()
|
||||||
|
|
||||||
|
def _requeue_provider_unavailable(self, project_id: str) -> None:
|
||||||
|
"""Keep a retained source pending while its worker transport is unavailable."""
|
||||||
|
try:
|
||||||
|
self.store.update_processing(project_id, status="queued")
|
||||||
|
except SimulationProjectError:
|
||||||
|
return
|
||||||
|
with self._condition:
|
||||||
|
cancel = self._cancel_events.get(project_id)
|
||||||
|
if cancel is not None and cancel.is_set():
|
||||||
|
return
|
||||||
|
if self._active_project_id == project_id and project_id not in self._queued_ids:
|
||||||
|
self._queue.append(project_id)
|
||||||
|
self._queued_ids.add(project_id)
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
def _ensure_import_capacity(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
artifacts: list[dict[str, Any]],
|
||||||
|
artifacts_root: Path,
|
||||||
|
) -> None:
|
||||||
|
required_bytes = _remaining_import_bytes(artifacts_root, artifacts)
|
||||||
|
available_bytes = shutil.disk_usage(artifacts_root).free
|
||||||
|
if available_bytes >= required_bytes:
|
||||||
|
return
|
||||||
|
project = self.store.get(project_id)
|
||||||
|
source = project.get("source")
|
||||||
|
provider = project.get("provider")
|
||||||
|
if (
|
||||||
|
isinstance(source, dict)
|
||||||
|
and source.get("kind") == "archive"
|
||||||
|
and isinstance(provider, dict)
|
||||||
|
and isinstance(provider.get("job_id"), str)
|
||||||
|
):
|
||||||
|
source_files = source.get("files")
|
||||||
|
if isinstance(source_files, list) and len(source_files) == 1:
|
||||||
|
source_file = source_files[0]
|
||||||
|
if isinstance(source_file, dict):
|
||||||
|
logical_path = source_file.get("logical_path")
|
||||||
|
byte_length = source_file.get("byte_length")
|
||||||
|
if isinstance(logical_path, str) and isinstance(byte_length, int):
|
||||||
|
source_path = _confined_path(
|
||||||
|
self.store.source_root(project_id),
|
||||||
|
logical_path,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
source_stat = source_path.stat()
|
||||||
|
except OSError:
|
||||||
|
source_stat = None
|
||||||
|
if (
|
||||||
|
source_stat is not None
|
||||||
|
and source_path.is_file()
|
||||||
|
and not source_path.is_symlink()
|
||||||
|
and source_stat.st_size == byte_length
|
||||||
|
):
|
||||||
|
source_path.unlink()
|
||||||
|
available_bytes = shutil.disk_usage(artifacts_root).free
|
||||||
|
if available_bytes < required_bytes:
|
||||||
|
raise SimulationProjectError(
|
||||||
|
"Недостаточно места для импорта Gaussian-мира: "
|
||||||
|
f"нужно {_human_bytes(required_bytes)}, "
|
||||||
|
f"доступно {_human_bytes(available_bytes)}."
|
||||||
|
)
|
||||||
|
|
||||||
def delete(self, project_id: str) -> None:
|
def delete(self, project_id: str) -> None:
|
||||||
project = self.store.get(project_id)
|
project = self.store.get(project_id)
|
||||||
with self._condition:
|
with self._condition:
|
||||||
@@ -794,7 +861,7 @@ class SimulationProjectService:
|
|||||||
raise _SimulationProcessingCancelled(project_id)
|
raise _SimulationProcessingCancelled(project_id)
|
||||||
|
|
||||||
|
|
||||||
def _retry_provider_unavailable(operation: Callable[[], _T]) -> _T:
|
def _retry_provider_unavailable[T](operation: Callable[[], T]) -> T:
|
||||||
delay_seconds = 1.0
|
delay_seconds = 1.0
|
||||||
for attempt in range(PROVIDER_UNAVAILABLE_RETRY_LIMIT):
|
for attempt in range(PROVIDER_UNAVAILABLE_RETRY_LIMIT):
|
||||||
try:
|
try:
|
||||||
@@ -841,6 +908,36 @@ def _artifact_descriptors(value: object) -> list[dict[str, Any]]:
|
|||||||
return descriptors
|
return descriptors
|
||||||
|
|
||||||
|
|
||||||
|
def _remaining_import_bytes(
|
||||||
|
artifacts_root: Path,
|
||||||
|
artifacts: list[dict[str, Any]],
|
||||||
|
) -> int:
|
||||||
|
missing_bytes = 0
|
||||||
|
replacement_scratch_bytes = 0
|
||||||
|
for descriptor in artifacts:
|
||||||
|
logical_path = str(descriptor["logical_path"])
|
||||||
|
expected_bytes = int(descriptor["byte_length"])
|
||||||
|
target = _confined_path(artifacts_root, logical_path)
|
||||||
|
try:
|
||||||
|
current = target.stat()
|
||||||
|
except OSError:
|
||||||
|
current = None
|
||||||
|
if (
|
||||||
|
current is not None
|
||||||
|
and target.is_file()
|
||||||
|
and not target.is_symlink()
|
||||||
|
and current.st_size == expected_bytes
|
||||||
|
):
|
||||||
|
replacement_scratch_bytes = max(replacement_scratch_bytes, expected_bytes)
|
||||||
|
else:
|
||||||
|
missing_bytes += expected_bytes
|
||||||
|
return missing_bytes + replacement_scratch_bytes + IMPORT_DISK_RESERVE_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def _human_bytes(value: int) -> str:
|
||||||
|
return f"{value / (1024**3):.2f} ГиБ"
|
||||||
|
|
||||||
|
|
||||||
def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[str, Any]:
|
def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
def url_for(role: str) -> str | None:
|
def url_for(role: str) -> str | None:
|
||||||
descriptor = next((item for item in artifacts if item["role"] == role), None)
|
descriptor = next((item for item in artifacts if item["role"] == role), None)
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ _E37_RESULT_ID = re.compile(r"^e37-ravnoves-acceptance-[a-f0-9]{64}$")
|
|||||||
_E38_RESULT_ID = re.compile(r"^e38-perception-baseline-[a-f0-9]{64}$")
|
_E38_RESULT_ID = re.compile(r"^e38-perception-baseline-[a-f0-9]{64}$")
|
||||||
_E39_RESULT_ID = re.compile(r"^e39-perception-refinement-[a-f0-9]{64}$")
|
_E39_RESULT_ID = re.compile(r"^e39-perception-refinement-[a-f0-9]{64}$")
|
||||||
_E40_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
_E40_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
||||||
|
_M4_RESULT_ID = re.compile(r"^m4-threat-replay-[a-f0-9]{64}$")
|
||||||
|
_M49_TGS_RESULT_ID = re.compile(r"^m49-tgs-full-shadow-[a-f0-9]{64}$")
|
||||||
|
_VEGETATION_RESULT_ID = re.compile(r"^lab-v1-vegetation-shadow-[a-f0-9]{64}$")
|
||||||
|
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
|
|
||||||
@@ -245,6 +248,7 @@ def _advanced_index_item(
|
|||||||
raise ValueError("advanced LAB authority is invalid")
|
raise ValueError("advanced LAB authority is invalid")
|
||||||
if document.get("ground_truth") not in (None, False):
|
if document.get("ground_truth") not in (None, False):
|
||||||
raise ValueError("advanced LAB ground-truth claim is invalid")
|
raise ValueError("advanced LAB ground-truth claim is invalid")
|
||||||
|
_validate_product_publication_shape(document, work_id=work_id)
|
||||||
created_at_utc = document.get("created_at_utc")
|
created_at_utc = document.get("created_at_utc")
|
||||||
if not isinstance(created_at_utc, str) or not created_at_utc.strip():
|
if not isinstance(created_at_utc, str) or not created_at_utc.strip():
|
||||||
raise ValueError("advanced LAB creation time is invalid")
|
raise ValueError("advanced LAB creation time is invalid")
|
||||||
@@ -256,6 +260,45 @@ def _advanced_index_item(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_product_publication_shape(
|
||||||
|
document: dict[str, Any],
|
||||||
|
*,
|
||||||
|
work_id: str,
|
||||||
|
) -> None:
|
||||||
|
if work_id != "lab-v1-vegetation-shadow":
|
||||||
|
return
|
||||||
|
full_route = document.get("route_full_review")
|
||||||
|
if isinstance(full_route, dict):
|
||||||
|
if (
|
||||||
|
full_route.get("source_id") != "RAVNOVES004TREE"
|
||||||
|
or full_route.get("session_id") != "20260828T130511Z_viewer_live"
|
||||||
|
or full_route.get("frame_count") != 6830
|
||||||
|
or _VEGETATION_RESULT_ID.fullmatch(
|
||||||
|
str(full_route.get("linked_route_review_result_id", ""))
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
or document.get("route_video") is not None
|
||||||
|
or document.get("route_review") is not None
|
||||||
|
):
|
||||||
|
raise ValueError("vegetation LAB has no canonical RAVNOVES004TREE publication shape")
|
||||||
|
return
|
||||||
|
route = document.get("route_video")
|
||||||
|
fusion = route.get("fusion") if isinstance(route, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(route, dict)
|
||||||
|
or route.get("view_kind") != "coarse-material-policy-review"
|
||||||
|
or _M4_RESULT_ID.fullmatch(str(route.get("base_m4_result_id", ""))) is None
|
||||||
|
or _M49_TGS_RESULT_ID.fullmatch(str(route.get("linked_tgs_result_id", "")))
|
||||||
|
is None
|
||||||
|
or not isinstance(fusion, dict)
|
||||||
|
or fusion.get("mode") != "synchronised-multilayer-review"
|
||||||
|
or fusion.get("pixel_raster_fusion") is not False
|
||||||
|
or document.get("route_review") is not None
|
||||||
|
or document.get("route_full_review") is not None
|
||||||
|
):
|
||||||
|
raise ValueError("vegetation LAB has no canonical M4/M4.9 publication shape")
|
||||||
|
|
||||||
|
|
||||||
def _advanced_index(
|
def _advanced_index(
|
||||||
specs: tuple[_AdvancedIndexSpec, ...],
|
specs: tuple[_AdvancedIndexSpec, ...],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
|||||||
@@ -360,6 +360,15 @@ def _m48_recorded_camera_playback_source(
|
|||||||
return session_recorded_camera_frame_service.playback_source(session_id)
|
return session_recorded_camera_frame_service.playback_source(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_lab_recording_source(session_id: str) -> tuple[Path, str] | None:
|
||||||
|
"""Resolve one already-published immutable RRD without starting new work."""
|
||||||
|
|
||||||
|
snapshot = session_recording_preparation_manager.status(session_id)
|
||||||
|
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
|
||||||
|
return None
|
||||||
|
return snapshot.recording.path, snapshot.recording.sha256
|
||||||
|
|
||||||
|
|
||||||
def refresh_observation_catalog() -> tuple[str, ...]:
|
def refresh_observation_catalog() -> tuple[str, ...]:
|
||||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||||
|
|
||||||
@@ -1032,6 +1041,12 @@ app.include_router(
|
|||||||
/ "lab-v1-vegetation"
|
/ "lab-v1-vegetation"
|
||||||
/ "results"
|
/ "results"
|
||||||
),
|
),
|
||||||
|
canonical_recording_provider=_canonical_lab_recording_source,
|
||||||
|
camera_frame_provider=(
|
||||||
|
session_recorded_camera_frame_service.extract
|
||||||
|
if session_recorded_camera_frame_service is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ from k1link.sessions import (
|
|||||||
SessionStore,
|
SessionStore,
|
||||||
validate_recorded_media_timeline,
|
validate_recorded_media_timeline,
|
||||||
)
|
)
|
||||||
|
from k1link.sessions.canonical_lab_spatial import (
|
||||||
|
CANONICAL_LAB_SPATIAL_PROFILE,
|
||||||
|
canonical_lab_spatial_frame,
|
||||||
|
)
|
||||||
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
from k1link.sessions.plugin_contract import RecordedPointColorRenderer
|
||||||
from k1link.viewer.recorded import (
|
from k1link.viewer.recorded import (
|
||||||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||||||
@@ -824,6 +828,80 @@ def build_session_router(
|
|||||||
**response_kwargs,
|
**response_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
|
||||||
|
)
|
||||||
|
async def get_observation_session_canonical_lab_spatial_frame(
|
||||||
|
session_id: str,
|
||||||
|
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||||
|
time_ns: Annotated[int, Query(ge=0, le=MAX_SAFE_INTEGER)],
|
||||||
|
profile: Literal["source-paced-ground-v3"],
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Serve one body-frame sample for the canonical recorded-LAB clock.
|
||||||
|
|
||||||
|
The camera media clock owns playback. Spatial evidence is sampled from
|
||||||
|
the same immutable recording instead of starting a second Rerun clock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if SAFE_SHA256.fullmatch(generation) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=412,
|
||||||
|
detail="Поколение spatial-записи не совпадает.",
|
||||||
|
)
|
||||||
|
if recording_preparation_manager is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Сервис canonical LAB spatial playback не настроен.",
|
||||||
|
)
|
||||||
|
snapshot = recording_preparation_manager.status(session_id)
|
||||||
|
if snapshot is None or snapshot.state != "ready" or snapshot.recording is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Запись canonical LAB ещё не подготовлена.",
|
||||||
|
)
|
||||||
|
_require_matching_recording_generation(snapshot.recording.sha256, generation)
|
||||||
|
pinned = recording_preparation_manager.pin_ready(
|
||||||
|
session_id,
|
||||||
|
preparation_id=snapshot.preparation_id,
|
||||||
|
)
|
||||||
|
if pinned is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=412,
|
||||||
|
detail="Подготовленная spatial-запись была заменена.",
|
||||||
|
)
|
||||||
|
pinned_snapshot, release_recording = pinned
|
||||||
|
try:
|
||||||
|
recording = pinned_snapshot.recording
|
||||||
|
if recording is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Подготовленная spatial-запись недоступна.",
|
||||||
|
)
|
||||||
|
payload = await run_in_threadpool(
|
||||||
|
canonical_lab_spatial_frame,
|
||||||
|
recording.path,
|
||||||
|
generation,
|
||||||
|
time_ns,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Canonical LAB spatial frame не прошёл проверку.",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
release_recording()
|
||||||
|
return JSONResponse(
|
||||||
|
payload,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"ETag": (
|
||||||
|
f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:'
|
||||||
|
f'{payload["source_time_ns"]}"'
|
||||||
|
),
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
|
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
|
||||||
async def get_observation_session_blueprint(
|
async def get_observation_session_blueprint(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
from k1link.laboratory.evidence_registry import LaboratoryEvidenceDefinition
|
||||||
from k1link.laboratory.evidence_report import (
|
from k1link.laboratory.evidence_report import (
|
||||||
@@ -20,9 +25,17 @@ from k1link.laboratory.evidence_report import (
|
|||||||
verify_laboratory_evidence_result,
|
verify_laboratory_evidence_result,
|
||||||
)
|
)
|
||||||
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
|
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
|
||||||
|
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
|
||||||
|
from k1link.sessions.canonical_lab_spatial import (
|
||||||
|
canonical_lab_spatial_playback_points,
|
||||||
|
canonical_lab_spatial_timeline_samples,
|
||||||
|
)
|
||||||
|
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
|
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
|
||||||
|
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
|
||||||
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024
|
||||||
|
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 24
|
||||||
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
||||||
work_id="lab-v1-vegetation-shadow",
|
work_id="lab-v1-vegetation-shadow",
|
||||||
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
|
||||||
@@ -40,12 +53,17 @@ _BENCHMARK_DEFINITION: Final = LaboratoryEvidenceDefinition(
|
|||||||
|
|
||||||
|
|
||||||
def build_vegetation_shadow_lab_router(
|
def build_vegetation_shadow_lab_router(
|
||||||
*, root_provider: RootProvider = lambda: None,
|
*,
|
||||||
|
root_provider: RootProvider = lambda: None,
|
||||||
|
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||||
|
camera_frame_provider: CameraFrameProvider | None = None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
return _build_vegetation_lab_router(
|
return _build_vegetation_lab_router(
|
||||||
prefix="/api/v1/laboratory/vegetation-shadow",
|
prefix="/api/v1/laboratory/vegetation-shadow",
|
||||||
definition=_DEFINITION,
|
definition=_DEFINITION,
|
||||||
root_provider=root_provider,
|
root_provider=root_provider,
|
||||||
|
canonical_recording_provider=canonical_recording_provider,
|
||||||
|
camera_frame_provider=camera_frame_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +82,8 @@ def _build_vegetation_lab_router(
|
|||||||
prefix: str,
|
prefix: str,
|
||||||
definition: LaboratoryEvidenceDefinition,
|
definition: LaboratoryEvidenceDefinition,
|
||||||
root_provider: RootProvider,
|
root_provider: RootProvider,
|
||||||
|
canonical_recording_provider: CanonicalRecordingProvider | None = None,
|
||||||
|
camera_frame_provider: CameraFrameProvider | None = None,
|
||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
@@ -150,19 +170,7 @@ def _build_vegetation_lab_router(
|
|||||||
archive_path = candidate.joinpath(*relative.parts)
|
archive_path = candidate.joinpath(*relative.parts)
|
||||||
member = f"masks/frame-{sequence + 1:06d}.png"
|
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||||
try:
|
try:
|
||||||
before = archive_path.stat()
|
payload = _read_cached_mask_member(archive_path, member)
|
||||||
with zipfile.ZipFile(archive_path) as frozen:
|
|
||||||
info = frozen.getinfo(member)
|
|
||||||
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
|
|
||||||
raise ValueError("Vegetation video mask member is invalid")
|
|
||||||
payload = frozen.read(info)
|
|
||||||
after = archive_path.stat()
|
|
||||||
if (
|
|
||||||
before.st_size != after.st_size
|
|
||||||
or before.st_mtime_ns != after.st_mtime_ns
|
|
||||||
or len(payload) != info.file_size
|
|
||||||
):
|
|
||||||
raise ValueError("Vegetation video mask archive changed during read")
|
|
||||||
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -179,9 +187,815 @@ def _build_vegetation_lab_router(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/route-masks/{layer}/{sequence}")
|
||||||
|
def get_full_route_mask(result_id: str, layer: str, sequence: int) -> Response:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route = manifest.get("route_full_review")
|
||||||
|
layers = route.get("layers") if isinstance(route, dict) else None
|
||||||
|
frame_count = route.get("frame_count") if isinstance(route, dict) else None
|
||||||
|
selected = layers.get(layer) if isinstance(layers, dict) else None
|
||||||
|
archive = selected.get("mask_archive") if isinstance(selected, dict) else None
|
||||||
|
archive_relative = archive.get("path") if isinstance(archive, dict) else None
|
||||||
|
if (
|
||||||
|
layer not in {"city", "vegetation"}
|
||||||
|
or not isinstance(frame_count, int)
|
||||||
|
or not 0 <= sequence < frame_count
|
||||||
|
or not isinstance(archive_relative, str)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route semantic mask not found")
|
||||||
|
relative = PurePosixPath(archive_relative)
|
||||||
|
artifacts = manifest.get("artifacts")
|
||||||
|
if (
|
||||||
|
relative.is_absolute()
|
||||||
|
or str(relative) != archive_relative
|
||||||
|
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||||
|
or relative.suffix != ".zip"
|
||||||
|
or not isinstance(artifacts, list)
|
||||||
|
or not any(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and item.get("path") == archive_relative
|
||||||
|
and item.get("media_type") == "application/zip"
|
||||||
|
for item in artifacts
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route semantic mask not found")
|
||||||
|
return _zip_mask_response(candidate.joinpath(*relative.parts), sequence)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/route-timeline")
|
||||||
|
def get_full_route_timeline(result_id: str) -> FileResponse:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route = manifest.get("route_full_review")
|
||||||
|
timeline = route.get("timeline") if isinstance(route, dict) else None
|
||||||
|
relative_text = timeline.get("path") if isinstance(timeline, dict) else None
|
||||||
|
frame_count = timeline.get("frame_count") if isinstance(timeline, dict) else None
|
||||||
|
byte_length = timeline.get("byte_length") if isinstance(timeline, dict) else None
|
||||||
|
sha256 = timeline.get("sha256") if isinstance(timeline, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(relative_text, str)
|
||||||
|
or frame_count != route.get("frame_count")
|
||||||
|
or byte_length != frame_count * 8
|
||||||
|
or not isinstance(sha256, str)
|
||||||
|
or len(sha256) != 64
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route timeline not found")
|
||||||
|
relative = PurePosixPath(relative_text)
|
||||||
|
artifacts = manifest.get("artifacts")
|
||||||
|
if (
|
||||||
|
relative.is_absolute()
|
||||||
|
or str(relative) != relative_text
|
||||||
|
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||||
|
or not isinstance(artifacts, list)
|
||||||
|
or not any(
|
||||||
|
isinstance(item, dict)
|
||||||
|
and item.get("path") == relative_text
|
||||||
|
and item.get("byte_length") == byte_length
|
||||||
|
and item.get("sha256") == sha256
|
||||||
|
and item.get("media_type") == "application/octet-stream"
|
||||||
|
for item in artifacts
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route timeline not found")
|
||||||
|
path = candidate.joinpath(*relative.parts)
|
||||||
|
if not path.is_file() or path.is_symlink() or path.stat().st_size != byte_length:
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route timeline not found")
|
||||||
|
return FileResponse(
|
||||||
|
path,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"ETag": f'"{sha256}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline")
|
||||||
|
def get_canonical_route_timeline(result_id: str) -> dict[str, object]:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
intervals = [
|
||||||
|
(current - previous) / 1_000_000_000
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
|
]
|
||||||
|
nominal_interval = statistics.median(intervals)
|
||||||
|
if not math.isfinite(nominal_interval) or nominal_interval <= 0:
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route timeline cadence is invalid")
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-evidence-timeline/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"recorded_source": {
|
||||||
|
"session_id": route["session_id"],
|
||||||
|
"source_id": route["source_id"],
|
||||||
|
"representation_id": "registered-map-increment-v1",
|
||||||
|
"synchronization": "host-arrival-best-effort",
|
||||||
|
},
|
||||||
|
"frame_count": len(frame_times_ns),
|
||||||
|
"frame_times_ns": list(frame_times_ns),
|
||||||
|
"timeline_start_seconds": frame_times_ns[0] / 1_000_000_000,
|
||||||
|
"timeline_end_seconds": frame_times_ns[-1] / 1_000_000_000,
|
||||||
|
"nominal_frame_interval_seconds": nominal_interval,
|
||||||
|
"nominal_rate_hz": 1.0 / nominal_interval,
|
||||||
|
"max_chunk_frames": _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||||
|
"point_sample_limit": 100_000,
|
||||||
|
"maximum_source_points_per_frame": 100_000,
|
||||||
|
"point_delivery": "exact-current-increment",
|
||||||
|
"world_state_frame_count": len(frame_times_ns),
|
||||||
|
"superseded_frame_count": 0,
|
||||||
|
"local_surface_visualization": {
|
||||||
|
"derivation": "bounded-registered-increment-accumulation",
|
||||||
|
"window_seconds": 5.0,
|
||||||
|
"voxel_size_m": 0.12,
|
||||||
|
"radius_m": 30.0,
|
||||||
|
"point_limit": 27_000,
|
||||||
|
"authority": "visual-derived",
|
||||||
|
},
|
||||||
|
"image_width": route["width"],
|
||||||
|
"image_height": route["height"],
|
||||||
|
"rig": {"length_m": 1.0, "width_m": 0.8, "nominal_sensor_height_m": 0.4},
|
||||||
|
"corridor": {
|
||||||
|
"forward_length_m": 8.0,
|
||||||
|
"rear_margin_m": 0.5,
|
||||||
|
"occupied_voxel_size_m": 0.45,
|
||||||
|
"half_width_m": 0.6,
|
||||||
|
"prediction_horizon_seconds": 8.0,
|
||||||
|
},
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-bounded-recorded-replay",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/chunk")
|
||||||
|
def get_canonical_route_timeline_chunk(
|
||||||
|
result_id: str,
|
||||||
|
start: int = 0,
|
||||||
|
count: int = _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||||
|
include_points: bool = True,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if start < 0 or not 1 <= count <= _CANONICAL_ROUTE_CHUNK_FRAMES:
|
||||||
|
raise HTTPException(status_code=422, detail="Full-route timeline chunk is invalid")
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
if start >= len(frame_times_ns):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route timeline chunk not found")
|
||||||
|
if canonical_recording_provider is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Canonical spatial recording is unavailable",
|
||||||
|
)
|
||||||
|
recording = canonical_recording_provider(str(route["session_id"]))
|
||||||
|
if recording is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
||||||
|
recording_path, generation_sha256 = recording
|
||||||
|
try:
|
||||||
|
samples = canonical_lab_spatial_timeline_samples(
|
||||||
|
recording_path,
|
||||||
|
generation_sha256,
|
||||||
|
frame_times_ns,
|
||||||
|
start,
|
||||||
|
count,
|
||||||
|
include_local_slam=False,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None
|
||||||
|
stop = start + len(samples)
|
||||||
|
frames = [
|
||||||
|
_canonical_timeline_frame(
|
||||||
|
result_id=result_id,
|
||||||
|
endpoint_prefix=prefix,
|
||||||
|
candidate=candidate,
|
||||||
|
route=route,
|
||||||
|
sequence=sequence,
|
||||||
|
source_time_ns=frame_times_ns[sequence],
|
||||||
|
spatial=sample,
|
||||||
|
include_points=include_points,
|
||||||
|
)
|
||||||
|
for sequence, sample in zip(range(start, stop), samples, strict=True)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-evidence-chunk/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"start_sequence": start,
|
||||||
|
"frame_count": len(frames),
|
||||||
|
"next_sequence": stop if stop < len(frame_times_ns) else None,
|
||||||
|
"frames": frames,
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-bounded-recorded-replay",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/playback")
|
||||||
|
def get_canonical_route_timeline_playback(result_id: str) -> dict[str, object]:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
points, offsets = _canonical_route_playback(
|
||||||
|
canonical_recording_provider,
|
||||||
|
route,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
points_view = memoryview(points).cast("B")
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-playback/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"frame_count": len(frame_times_ns),
|
||||||
|
"point_count": int(points.shape[0]),
|
||||||
|
"point_offsets": list(offsets),
|
||||||
|
"chunk_frame_count": _CANONICAL_ROUTE_CHUNK_FRAMES,
|
||||||
|
"resident_chunk_count_max": 4,
|
||||||
|
"forward_prefetch_chunk_count": 1,
|
||||||
|
"chunks": _canonical_route_playback_chunk_catalog(
|
||||||
|
prefix,
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
),
|
||||||
|
"track": {
|
||||||
|
"id": "points-map-f32",
|
||||||
|
"url": f"{prefix}/{result_id}/timeline/playback/tracks/points-map-f32",
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
"dtype": "<f4",
|
||||||
|
"shape": [int(points.shape[0]), 3],
|
||||||
|
"bytes": int(points.nbytes),
|
||||||
|
"sha256": hashlib.sha256(points_view).hexdigest(),
|
||||||
|
},
|
||||||
|
"coordinate_frame": "map",
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
"access": "read-only-sealed-binary-playback",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/playback/chunks/{chunk_index}")
|
||||||
|
def get_canonical_route_timeline_playback_chunk(
|
||||||
|
result_id: str,
|
||||||
|
chunk_index: int,
|
||||||
|
) -> Response:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
points, offsets = _canonical_route_playback(
|
||||||
|
canonical_recording_provider,
|
||||||
|
route,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
points_view = memoryview(points).cast("B")
|
||||||
|
descriptor = _canonical_route_playback_chunk_descriptor(
|
||||||
|
prefix,
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
chunk_index,
|
||||||
|
)
|
||||||
|
if descriptor is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route playback chunk not found")
|
||||||
|
point_start = int(descriptor["point_start"])
|
||||||
|
byte_length = int(descriptor["bytes"])
|
||||||
|
byte_start = point_start * 3 * 4
|
||||||
|
payload = bytes(points_view[byte_start : byte_start + byte_length])
|
||||||
|
return Response(
|
||||||
|
content=payload,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers=_immutable_binary_headers(byte_length, str(descriptor["sha256"])),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/playback/tracks/points-map-f32")
|
||||||
|
def get_canonical_route_timeline_playback_track(result_id: str) -> Response:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
points, _ = _canonical_route_playback(
|
||||||
|
canonical_recording_provider,
|
||||||
|
route,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
payload = memoryview(points).cast("B")
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
return Response(
|
||||||
|
content=bytes(payload),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers=_immutable_binary_headers(payload.nbytes, digest),
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/timeline/frames/{sequence}/camera")
|
||||||
|
def get_canonical_route_camera(result_id: str, sequence: int) -> Response:
|
||||||
|
if camera_frame_provider is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Recorded camera decoder is unavailable")
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
route, frame_times_ns = _full_route_context(candidate, manifest)
|
||||||
|
if not 0 <= sequence < len(frame_times_ns):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route camera frame not found")
|
||||||
|
try:
|
||||||
|
camera = camera_frame_provider(str(route["session_id"]), sequence)
|
||||||
|
except (OSError, SessionIntegrityError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Full-route camera frame unavailable",
|
||||||
|
) from None
|
||||||
|
if camera.width != route["width"] or camera.height != route["height"]:
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route camera dimensions changed")
|
||||||
|
return Response(
|
||||||
|
content=camera.payload,
|
||||||
|
media_type=camera.media_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"ETag": f'"{camera.sha256}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{result_id}/route-tgs-anchor/{source_sequence}")
|
||||||
|
def get_route_tgs_anchor(result_id: str, source_sequence: int) -> JSONResponse:
|
||||||
|
candidate = _resolve_candidate(root_provider, definition, result_id)
|
||||||
|
manifest = _read_verified(candidate, definition)
|
||||||
|
review = manifest.get("route_review")
|
||||||
|
cases = review.get("cases") if isinstance(review, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(cases, list)
|
||||||
|
or review.get("source_id") != "RAVNOVES004TREE"
|
||||||
|
or review.get("session_id") != "20260828T130511Z_viewer_live"
|
||||||
|
or not any(
|
||||||
|
isinstance(item, dict) and item.get("source_sequence") == source_sequence
|
||||||
|
for item in cases
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
|
||||||
|
artifacts = manifest.get("artifacts")
|
||||||
|
descriptor = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in artifacts if isinstance(item, dict)
|
||||||
|
and item.get("role") == "mixed-route-tgs-evidence"
|
||||||
|
and item.get("path") == "proofs/tgs-evidence.npz"
|
||||||
|
and item.get("media_type") == "application/x-npz"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
) if isinstance(artifacts, list) else None
|
||||||
|
if descriptor is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Route TGS anchor not found")
|
||||||
|
try:
|
||||||
|
payload = _route_tgs_anchor_payload(
|
||||||
|
candidate / "proofs" / "tgs-evidence.npz",
|
||||||
|
source_sequence,
|
||||||
|
)
|
||||||
|
except (KeyError, OSError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Route TGS anchor failed verification",
|
||||||
|
) from None
|
||||||
|
return JSONResponse(
|
||||||
|
payload,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"ETag": f'"{descriptor.get("sha256", "")}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _full_route_context(
|
||||||
|
candidate: Path,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], tuple[int, ...]]:
|
||||||
|
route = manifest.get("route_full_review")
|
||||||
|
timeline = route.get("timeline") if isinstance(route, dict) else None
|
||||||
|
relative_text = timeline.get("path") if isinstance(timeline, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(route, dict)
|
||||||
|
or route.get("source_id") != "RAVNOVES004TREE"
|
||||||
|
or route.get("session_id") != "20260828T130511Z_viewer_live"
|
||||||
|
or route.get("frame_count") != 6830
|
||||||
|
or route.get("width") != 800
|
||||||
|
or route.get("height") != 600
|
||||||
|
or not isinstance(relative_text, str)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Full-route canonical timeline not found")
|
||||||
|
path = candidate.joinpath(*PurePosixPath(relative_text).parts)
|
||||||
|
try:
|
||||||
|
payload = path.read_bytes()
|
||||||
|
if (
|
||||||
|
len(payload) != timeline.get("byte_length")
|
||||||
|
or hashlib.sha256(payload).hexdigest() != timeline.get("sha256")
|
||||||
|
):
|
||||||
|
raise ValueError("timeline digest changed")
|
||||||
|
values = np.frombuffer(payload, dtype="<u8")
|
||||||
|
frame_times_ns = tuple(int(value) for value in values)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Full-route timeline verification failed",
|
||||||
|
) from None
|
||||||
|
if (
|
||||||
|
len(frame_times_ns) != route["frame_count"]
|
||||||
|
or any(
|
||||||
|
current <= previous
|
||||||
|
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=503, detail="Full-route timeline order changed")
|
||||||
|
return route, frame_times_ns
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_route_playback(
|
||||||
|
provider: CanonicalRecordingProvider | None,
|
||||||
|
route: dict[str, Any],
|
||||||
|
frame_times_ns: tuple[int, ...],
|
||||||
|
) -> tuple[np.ndarray, tuple[int, ...]]:
|
||||||
|
if provider is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable")
|
||||||
|
recording = provider(str(route["session_id"]))
|
||||||
|
if recording is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
|
||||||
|
recording_path, generation_sha256 = recording
|
||||||
|
try:
|
||||||
|
return canonical_lab_spatial_playback_points(
|
||||||
|
recording_path,
|
||||||
|
generation_sha256,
|
||||||
|
frame_times_ns,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(status_code=503, detail="Canonical spatial playback failed") from None
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_route_playback_chunk_descriptor(
|
||||||
|
endpoint_prefix: str,
|
||||||
|
result_id: str,
|
||||||
|
points_view: memoryview,
|
||||||
|
offsets: tuple[int, ...],
|
||||||
|
chunk_index: int,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
frame_count = len(offsets) - 1
|
||||||
|
start = chunk_index * _CANONICAL_ROUTE_CHUNK_FRAMES
|
||||||
|
if chunk_index < 0 or start >= frame_count:
|
||||||
|
return None
|
||||||
|
count = min(_CANONICAL_ROUTE_CHUNK_FRAMES, frame_count - start)
|
||||||
|
point_start = offsets[start]
|
||||||
|
point_stop = offsets[start + count]
|
||||||
|
byte_start = point_start * 3 * 4
|
||||||
|
byte_stop = point_stop * 3 * 4
|
||||||
|
payload = points_view[byte_start:byte_stop]
|
||||||
|
return {
|
||||||
|
"index": chunk_index,
|
||||||
|
"start": start,
|
||||||
|
"count": count,
|
||||||
|
"point_start": point_start,
|
||||||
|
"point_count": point_stop - point_start,
|
||||||
|
"url": f"{endpoint_prefix}/{result_id}/timeline/playback/chunks/{chunk_index}",
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
"dtype": "<f4",
|
||||||
|
"shape": [point_stop - point_start, 3],
|
||||||
|
"bytes": payload.nbytes,
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_route_playback_chunk_catalog(
|
||||||
|
endpoint_prefix: str,
|
||||||
|
result_id: str,
|
||||||
|
points_view: memoryview,
|
||||||
|
offsets: tuple[int, ...],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
frame_count = len(offsets) - 1
|
||||||
|
chunk_count = (
|
||||||
|
frame_count + _CANONICAL_ROUTE_CHUNK_FRAMES - 1
|
||||||
|
) // _CANONICAL_ROUTE_CHUNK_FRAMES
|
||||||
|
return [
|
||||||
|
descriptor
|
||||||
|
for chunk_index in range(chunk_count)
|
||||||
|
if (
|
||||||
|
descriptor := _canonical_route_playback_chunk_descriptor(
|
||||||
|
endpoint_prefix,
|
||||||
|
result_id,
|
||||||
|
points_view,
|
||||||
|
offsets,
|
||||||
|
chunk_index,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _immutable_binary_headers(byte_length: int, sha256: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"Content-Encoding": "identity",
|
||||||
|
"Content-Length": str(byte_length),
|
||||||
|
"ETag": f'"{sha256}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Uncompressed-Content-Length": str(byte_length),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_timeline_frame(
|
||||||
|
*,
|
||||||
|
result_id: str,
|
||||||
|
endpoint_prefix: str,
|
||||||
|
candidate: Path,
|
||||||
|
route: dict[str, Any],
|
||||||
|
sequence: int,
|
||||||
|
source_time_ns: int,
|
||||||
|
spatial: dict[str, object] | None,
|
||||||
|
include_points: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"]
|
||||||
|
point_count = 0 if spatial is None else int(spatial["source_point_count"])
|
||||||
|
body_frame = None if spatial is None else spatial["body_frame"]
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.recorded-spatial-evidence-frame/v1",
|
||||||
|
"sequence": sequence,
|
||||||
|
"frame_id": f"frame-{sequence:06d}",
|
||||||
|
"source_time_ns": source_time_ns,
|
||||||
|
"session_seconds": source_time_ns / 1_000_000_000,
|
||||||
|
"source_available": spatial is not None,
|
||||||
|
"spatial_available": spatial is not None,
|
||||||
|
"world_state_available": True,
|
||||||
|
"terminal_outcome": "delivered",
|
||||||
|
"body_frame": body_frame,
|
||||||
|
"point_cloud_body_xyz_m": points,
|
||||||
|
"point_cloud_source_count": point_count,
|
||||||
|
"point_cloud_sample_count": point_count if not include_points else len(points),
|
||||||
|
"point_cloud_layer": "current-increment",
|
||||||
|
"rolling_map_component_count": 0,
|
||||||
|
"metric_obstacles": [],
|
||||||
|
"camera_proposals": _semantic_component_proposals(candidate, route, sequence),
|
||||||
|
"decision_counts": {"threat": 0, "not-threat": 0, "unknown": 0},
|
||||||
|
"camera_url": (
|
||||||
|
f"{endpoint_prefix}/{result_id}/timeline/frames/{sequence}/camera"
|
||||||
|
),
|
||||||
|
"ground_truth": False,
|
||||||
|
"authority": "replay-simulated",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_component_proposals(
|
||||||
|
candidate: Path,
|
||||||
|
route: dict[str, Any],
|
||||||
|
sequence: int,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
layers = route.get("layers")
|
||||||
|
city = layers.get("city") if isinstance(layers, dict) else None
|
||||||
|
archive = city.get("mask_archive") if isinstance(city, dict) else None
|
||||||
|
relative = archive.get("path") if isinstance(archive, dict) else None
|
||||||
|
if not isinstance(relative, str):
|
||||||
|
return []
|
||||||
|
archive_path = candidate.joinpath(*PurePosixPath(relative).parts)
|
||||||
|
try:
|
||||||
|
stat = archive_path.stat()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
dict(proposal)
|
||||||
|
for proposal in _semantic_component_proposals_cached(
|
||||||
|
str(archive_path),
|
||||||
|
stat.st_size,
|
||||||
|
stat.st_mtime_ns,
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=256)
|
||||||
|
def _semantic_component_proposals_cached(
|
||||||
|
archive_path_text: str,
|
||||||
|
archive_size: int,
|
||||||
|
archive_mtime_ns: int,
|
||||||
|
sequence: int,
|
||||||
|
) -> tuple[dict[str, object], ...]:
|
||||||
|
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||||
|
try:
|
||||||
|
frozen = _cached_zip_archive(
|
||||||
|
archive_path_text,
|
||||||
|
archive_size,
|
||||||
|
archive_mtime_ns,
|
||||||
|
)
|
||||||
|
payload = frozen.read(member)
|
||||||
|
with Image.open(io.BytesIO(payload)) as image:
|
||||||
|
mask = np.asarray(image.convert("L"), dtype=np.uint8)
|
||||||
|
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||||
|
return ()
|
||||||
|
labels = {
|
||||||
|
1: "semantic person",
|
||||||
|
2: "semantic bicycle",
|
||||||
|
3: "semantic motorcycle",
|
||||||
|
4: "semantic car",
|
||||||
|
5: "semantic heavy vehicle",
|
||||||
|
13: "semantic static obstacle",
|
||||||
|
14: "semantic animal",
|
||||||
|
}
|
||||||
|
proposals: list[dict[str, object]] = []
|
||||||
|
for class_id, label in labels.items():
|
||||||
|
minimum_pixels = 80 if class_id == 13 else 24
|
||||||
|
for component_index, (left, top, right, bottom, pixel_count) in enumerate(
|
||||||
|
_mask_component_boxes(mask, class_id, minimum_pixels=minimum_pixels)[:12]
|
||||||
|
):
|
||||||
|
proposals.append({
|
||||||
|
"proposal_id": f"semantic-{class_id}-{sequence}-{component_index}",
|
||||||
|
"bbox_xyxy": [left, top, right, bottom],
|
||||||
|
"objectness": round(min(0.99, 0.5 + pixel_count / 20_000), 4),
|
||||||
|
"semantic_hint": label,
|
||||||
|
"occupied_support": False,
|
||||||
|
"range_m": None,
|
||||||
|
"threat_decision": None,
|
||||||
|
"threat_reason_codes": ["semantic-mask-derived-not-fail-safe-detector"],
|
||||||
|
})
|
||||||
|
proposals.sort(
|
||||||
|
key=lambda proposal: (
|
||||||
|
-float(proposal["objectness"]),
|
||||||
|
str(proposal["proposal_id"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(proposals[:32])
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_component_boxes(
|
||||||
|
mask: np.ndarray,
|
||||||
|
class_id: int,
|
||||||
|
*,
|
||||||
|
minimum_pixels: int,
|
||||||
|
) -> list[tuple[int, int, int, int, int]]:
|
||||||
|
"""Return 8-connected run-length components without an OpenCV dependency."""
|
||||||
|
|
||||||
|
if mask.ndim != 2 or minimum_pixels < 1:
|
||||||
|
return []
|
||||||
|
parents: list[int] = []
|
||||||
|
runs: list[tuple[int, int, int, int]] = []
|
||||||
|
|
||||||
|
def root(index: int) -> int:
|
||||||
|
while parents[index] != index:
|
||||||
|
parents[index] = parents[parents[index]]
|
||||||
|
index = parents[index]
|
||||||
|
return index
|
||||||
|
|
||||||
|
def union(left: int, right: int) -> None:
|
||||||
|
left_root = root(left)
|
||||||
|
right_root = root(right)
|
||||||
|
if left_root != right_root:
|
||||||
|
parents[right_root] = left_root
|
||||||
|
|
||||||
|
previous: list[int] = []
|
||||||
|
for row_index, row in enumerate(mask):
|
||||||
|
matches = np.flatnonzero(row == class_id)
|
||||||
|
if matches.size == 0:
|
||||||
|
previous = []
|
||||||
|
continue
|
||||||
|
split_at = np.flatnonzero(np.diff(matches) > 1) + 1
|
||||||
|
groups = np.split(matches, split_at)
|
||||||
|
current: list[int] = []
|
||||||
|
previous_cursor = 0
|
||||||
|
for group in groups:
|
||||||
|
start = int(group[0])
|
||||||
|
stop = int(group[-1]) + 1
|
||||||
|
run_index = len(runs)
|
||||||
|
runs.append((row_index, start, stop, stop - start))
|
||||||
|
parents.append(run_index)
|
||||||
|
current.append(run_index)
|
||||||
|
while (
|
||||||
|
previous_cursor < len(previous)
|
||||||
|
and runs[previous[previous_cursor]][2] < start
|
||||||
|
):
|
||||||
|
previous_cursor += 1
|
||||||
|
candidate_cursor = previous_cursor
|
||||||
|
while candidate_cursor < len(previous):
|
||||||
|
previous_index = previous[candidate_cursor]
|
||||||
|
_, previous_start, previous_stop, _ = runs[previous_index]
|
||||||
|
if previous_start > stop:
|
||||||
|
break
|
||||||
|
union(run_index, previous_index)
|
||||||
|
candidate_cursor += 1
|
||||||
|
previous = current
|
||||||
|
|
||||||
|
components: dict[int, list[int]] = {}
|
||||||
|
for run_index, (row, start, stop, count) in enumerate(runs):
|
||||||
|
component = components.setdefault(root(run_index), [start, row, stop, row + 1, 0])
|
||||||
|
component[0] = min(component[0], start)
|
||||||
|
component[1] = min(component[1], row)
|
||||||
|
component[2] = max(component[2], stop)
|
||||||
|
component[3] = max(component[3], row + 1)
|
||||||
|
component[4] += count
|
||||||
|
result = [
|
||||||
|
(left, top, right, bottom, count)
|
||||||
|
for left, top, right, bottom, count in components.values()
|
||||||
|
if count >= minimum_pixels and right - left >= 2 and bottom - top >= 3
|
||||||
|
]
|
||||||
|
result.sort(key=lambda box: (-box[4], box[1], box[0]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, object]:
|
||||||
|
before = path.stat()
|
||||||
|
with np.load(path, allow_pickle=False) as archive:
|
||||||
|
source_indices = archive["source_frame_indices"]
|
||||||
|
offsets = archive["current_increment_point_offsets"]
|
||||||
|
points = archive["current_increment_points_xyz_m"]
|
||||||
|
centers = archive["costmap_cell_centers_xy_m"]
|
||||||
|
states = archive["causal_rolling_1s_costmap_states"]
|
||||||
|
z_bounds = archive["causal_rolling_1s_costmap_z_bounds_m"]
|
||||||
|
if (
|
||||||
|
source_indices.shape != (10,)
|
||||||
|
or offsets.shape != (11,)
|
||||||
|
or points.ndim != 2
|
||||||
|
or points.shape[1] != 3
|
||||||
|
or centers.shape != (2244, 2)
|
||||||
|
or states.shape != (10, 2244)
|
||||||
|
or z_bounds.shape != (10, 2244, 2)
|
||||||
|
):
|
||||||
|
raise ValueError("Route TGS evidence shape changed")
|
||||||
|
matches = np.flatnonzero(source_indices == source_sequence - 1)
|
||||||
|
if matches.shape != (1,):
|
||||||
|
raise ValueError("Route TGS source sequence changed")
|
||||||
|
slot = int(matches[0])
|
||||||
|
start = int(offsets[slot])
|
||||||
|
end = int(offsets[slot + 1])
|
||||||
|
if not 0 <= start <= end <= points.shape[0]:
|
||||||
|
raise ValueError("Route TGS point offsets changed")
|
||||||
|
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
|
||||||
|
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
|
||||||
|
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
|
||||||
|
if (
|
||||||
|
not np.isfinite(selected_points).all()
|
||||||
|
or not np.isin(selected_states, [0, 1, 2, 3]).all()
|
||||||
|
):
|
||||||
|
raise ValueError("Route TGS payload changed")
|
||||||
|
result = {
|
||||||
|
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
|
||||||
|
"source_sequence": source_sequence,
|
||||||
|
"slot": slot,
|
||||||
|
"current_points_xyz_m": selected_points.astype(float).tolist(),
|
||||||
|
"costmap": {
|
||||||
|
"cell_size_m": 0.45,
|
||||||
|
"centers_xy_m": centers.astype(float).tolist(),
|
||||||
|
"state_codes": selected_states.astype(int).tolist(),
|
||||||
|
"z_bounds_m": [
|
||||||
|
[
|
||||||
|
float(row[0]) if np.isfinite(row[0]) else None,
|
||||||
|
float(row[1]) if np.isfinite(row[1]) else None,
|
||||||
|
]
|
||||||
|
for row in selected_z_bounds
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
after = path.stat()
|
||||||
|
if before.st_size != after.st_size or before.st_mtime_ns != after.st_mtime_ns:
|
||||||
|
raise ValueError("Route TGS evidence changed during read")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
|
||||||
|
member = f"masks/frame-{sequence + 1:06d}.png"
|
||||||
|
try:
|
||||||
|
payload = _read_cached_mask_member(archive_path, member)
|
||||||
|
except (KeyError, OSError, ValueError, zipfile.BadZipFile):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Semantic mask failed verification",
|
||||||
|
) from None
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
return Response(
|
||||||
|
content=payload,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "private, max-age=31536000, immutable",
|
||||||
|
"ETag": f'"{digest}"',
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def _cached_zip_archive(
|
||||||
|
archive_path_text: str,
|
||||||
|
archive_size: int,
|
||||||
|
archive_mtime_ns: int,
|
||||||
|
) -> zipfile.ZipFile:
|
||||||
|
del archive_size, archive_mtime_ns
|
||||||
|
return zipfile.ZipFile(archive_path_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cached_mask_member(archive_path: Path, member: str) -> bytes:
|
||||||
|
before = archive_path.stat()
|
||||||
|
frozen = _cached_zip_archive(
|
||||||
|
str(archive_path),
|
||||||
|
before.st_size,
|
||||||
|
before.st_mtime_ns,
|
||||||
|
)
|
||||||
|
info = frozen.getinfo(member)
|
||||||
|
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
|
||||||
|
raise ValueError("Semantic mask member is invalid")
|
||||||
|
payload = frozen.read(info)
|
||||||
|
after = archive_path.stat()
|
||||||
|
if (
|
||||||
|
before.st_size != after.st_size
|
||||||
|
or before.st_mtime_ns != after.st_mtime_ns
|
||||||
|
or len(payload) != info.file_size
|
||||||
|
):
|
||||||
|
raise ValueError("Semantic mask archive changed during read")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _configured_root(provider: RootProvider) -> Path | None:
|
def _configured_root(provider: RootProvider) -> Path | None:
|
||||||
candidate = provider()
|
candidate = provider()
|
||||||
if candidate is None:
|
if candidate is None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@@ -151,6 +152,84 @@ def test_advanced_index_projects_one_most_mature_lifecycle_phase(tmp_path: Path)
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_index_prefers_canonical_rav004_full_review(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "vegetation"
|
||||||
|
|
||||||
|
def publish(digest: str, *, publication: str) -> Path:
|
||||||
|
result_id = f"lab-v1-vegetation-shadow-{digest}"
|
||||||
|
candidate = root / result_id
|
||||||
|
candidate.mkdir(parents=True)
|
||||||
|
route_video = {
|
||||||
|
"view_kind": "coarse-material-policy-review",
|
||||||
|
"base_m4_result_id": f"m4-threat-replay-{'1' * 64}",
|
||||||
|
"linked_tgs_result_id": f"m49-tgs-full-shadow-{'2' * 64}",
|
||||||
|
"fusion": {
|
||||||
|
"mode": "synchronised-multilayer-review",
|
||||||
|
"pixel_raster_fusion": False,
|
||||||
|
},
|
||||||
|
} if publication == "rav00" else None
|
||||||
|
route_full_review = {
|
||||||
|
"source_id": "RAVNOVES004TREE",
|
||||||
|
"session_id": "20260828T130511Z_viewer_live",
|
||||||
|
"frame_count": 6830,
|
||||||
|
"linked_route_review_result_id": (
|
||||||
|
f"lab-v1-vegetation-shadow-{'4' * 64}"
|
||||||
|
if publication == "rav004"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
} if publication != "rav00" else None
|
||||||
|
(candidate / "manifest.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.lab-v1-vegetation-shadow/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity_sha256": digest,
|
||||||
|
"identity": {
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at_utc": "2026-08-29T10:00:00Z",
|
||||||
|
"ground_truth": False,
|
||||||
|
"route_video": route_video,
|
||||||
|
"route_review": None,
|
||||||
|
"route_full_review": route_full_review,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
rav00 = publish("a" * 64, publication="rav00")
|
||||||
|
incomplete = publish("b" * 64, publication="incomplete")
|
||||||
|
rav004 = publish("c" * 64, publication="rav004")
|
||||||
|
os.utime(rav00, ns=(10_000_000_000, 10_000_000_000))
|
||||||
|
os.utime(incomplete, ns=(20_000_000_000, 20_000_000_000))
|
||||||
|
os.utime(rav004, ns=(30_000_000_000, 30_000_000_000))
|
||||||
|
registry = _evidence_registry(
|
||||||
|
root,
|
||||||
|
work_id="lab-v1-vegetation-shadow",
|
||||||
|
result_id_prefix="lab-v1-vegetation-shadow",
|
||||||
|
schema_version="missioncore.lab-v1-vegetation-shadow/v1",
|
||||||
|
)
|
||||||
|
router = build_advanced_laboratory_router(
|
||||||
|
evidence_registry=registry,
|
||||||
|
evidence_runtime_root_provider=lambda: root.parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
index = _endpoint(router, "/api/v1/laboratory/advanced-index")()
|
||||||
|
|
||||||
|
assert index["items"] == [ # type: ignore[index]
|
||||||
|
{
|
||||||
|
"work_id": "lab-v1-vegetation-shadow",
|
||||||
|
"result_id": rav004.name,
|
||||||
|
"created_at_utc": "2026-08-29T10:00:00Z",
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_index_includes_valid_l31_identity(
|
def test_advanced_index_includes_valid_l31_identity(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: MonkeyPatch,
|
monkeypatch: MonkeyPatch,
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import k1link.sessions.canonical_lab_spatial as spatial_module
|
||||||
|
from k1link.sessions.canonical_lab_spatial import (
|
||||||
|
_bounded_local_slam,
|
||||||
|
_CanonicalSpatialIndex,
|
||||||
|
_estimate_local_sensor_height,
|
||||||
|
_estimate_sensor_height,
|
||||||
|
_gravity_stable_basis_map_from_body,
|
||||||
|
_ground_origin_map,
|
||||||
|
_TimedPoints,
|
||||||
|
_TimedPoses,
|
||||||
|
canonical_lab_spatial_playback_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calibration_cloud(height_m: float, seed: int) -> np.ndarray:
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
xy = rng.uniform(-5.5, 5.5, size=(500, 2)).astype(np.float32)
|
||||||
|
radius = np.linalg.norm(xy, axis=1)
|
||||||
|
xy = xy[(radius >= 1.0) & (radius <= 5.5)][:360]
|
||||||
|
ground = np.column_stack((
|
||||||
|
xy,
|
||||||
|
rng.normal(-height_m, 0.006, size=xy.shape[0]),
|
||||||
|
)).astype(np.float32)
|
||||||
|
vegetation = np.column_stack((
|
||||||
|
rng.uniform(-5, 5, size=(300, 2)),
|
||||||
|
rng.uniform(0.0, 1.2, size=300),
|
||||||
|
)).astype(np.float32)
|
||||||
|
return np.concatenate((ground, vegetation), axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_sensor_height_is_derived_from_initial_source_cloud() -> None:
|
||||||
|
times = tuple(index * 500_000_000 for index in range(12))
|
||||||
|
points = _TimedPoints(
|
||||||
|
times_ns=times,
|
||||||
|
values=tuple(_calibration_cloud(0.32, index) for index in range(12)),
|
||||||
|
)
|
||||||
|
poses = _TimedPoses(
|
||||||
|
times_ns=times,
|
||||||
|
translations=tuple(np.zeros(3) for _ in times),
|
||||||
|
quaternions_xyzw=tuple(np.asarray([0.0, 0.0, 0.0, 1.0]) for _ in times),
|
||||||
|
)
|
||||||
|
|
||||||
|
height, sample_count, mad = _estimate_sensor_height(points, poses)
|
||||||
|
|
||||||
|
assert height == pytest.approx(0.32, abs=0.02)
|
||||||
|
assert sample_count == 12
|
||||||
|
assert mad < 0.02
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_slam_accumulates_source_increments_in_ground_body_frame() -> None:
|
||||||
|
points = _TimedPoints(
|
||||||
|
times_ns=(0, 1_000_000_000, 2_000_000_000),
|
||||||
|
values=(
|
||||||
|
np.asarray([[1.0, 0.0, -0.32]], dtype=np.float32),
|
||||||
|
np.asarray([[2.0, 0.0, -0.32]], dtype=np.float32),
|
||||||
|
np.asarray([[3.0, 0.0, -0.32]], dtype=np.float32),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
basis = np.eye(3)
|
||||||
|
ground_origin = _ground_origin_map(np.asarray([0.0, 0.0, 0.0]), 0.32)
|
||||||
|
|
||||||
|
local, frame_count, source_count = _bounded_local_slam(
|
||||||
|
points,
|
||||||
|
2_000_000_000,
|
||||||
|
ground_origin,
|
||||||
|
basis,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert frame_count == 3
|
||||||
|
assert source_count == 3
|
||||||
|
assert local[:, 2].tolist() == pytest.approx([0.0, 0.0, 0.0], abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gravity_stable_body_frame_converts_rfu_to_forward_left_up() -> None:
|
||||||
|
times = (0, 1_000_000_000, 2_000_000_000)
|
||||||
|
poses = _TimedPoses(
|
||||||
|
times_ns=times,
|
||||||
|
translations=(
|
||||||
|
np.asarray([0.0, 0.0, 0.4]),
|
||||||
|
np.asarray([0.0, 1.0, 0.5]),
|
||||||
|
np.asarray([0.0, 2.0, 0.3]),
|
||||||
|
),
|
||||||
|
quaternions_xyzw=tuple(
|
||||||
|
np.asarray([0.25, 0.0, 0.0, np.sqrt(1.0 - 0.25**2)]) for _ in times
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
basis, source = _gravity_stable_basis_map_from_body(poses, 1_000_000_000)
|
||||||
|
|
||||||
|
assert source == "smoothed-pose-trajectory-tangent"
|
||||||
|
assert basis[:, 0].tolist() == pytest.approx([0.0, 1.0, 0.0], abs=1e-7)
|
||||||
|
assert basis[:, 1].tolist() == pytest.approx([-1.0, 0.0, 0.0], abs=1e-7)
|
||||||
|
assert basis[:, 2].tolist() == pytest.approx([0.0, 0.0, 1.0], abs=1e-7)
|
||||||
|
assert np.linalg.det(basis) == pytest.approx(1.0, abs=1e-7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ground_origin_is_projected_only_along_map_gravity() -> None:
|
||||||
|
origin = _ground_origin_map(np.asarray([4.0, -2.0, 1.25]), 0.32)
|
||||||
|
assert origin.tolist() == pytest.approx([4.0, -2.0, 0.93], abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_height_tracks_current_source_window_instead_of_fixed_mount() -> None:
|
||||||
|
times = tuple(index * 500_000_000 for index in range(8))
|
||||||
|
points = _TimedPoints(
|
||||||
|
times_ns=times,
|
||||||
|
values=tuple(
|
||||||
|
_calibration_cloud(0.18 if index < 4 else 1.05, index)
|
||||||
|
for index in range(8)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
poses = _TimedPoses(
|
||||||
|
times_ns=times,
|
||||||
|
translations=tuple(np.zeros(3) for _ in times),
|
||||||
|
quaternions_xyzw=tuple(np.asarray([0.0, 0.0, 0.0, 1.0]) for _ in times),
|
||||||
|
)
|
||||||
|
|
||||||
|
low, low_samples, _, low_source = _estimate_local_sensor_height(
|
||||||
|
points, poses, 500_000_000, 0.5,
|
||||||
|
)
|
||||||
|
high, high_samples, _, high_source = _estimate_local_sensor_height(
|
||||||
|
points, poses, 3_000_000_000, 0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert low == pytest.approx(0.18, abs=0.03)
|
||||||
|
assert high == pytest.approx(1.05, abs=0.03)
|
||||||
|
assert low_samples >= 3 and high_samples >= 3
|
||||||
|
assert low_source == high_source == "local-source-cloud-ground-quantile-median"
|
||||||
|
|
||||||
|
|
||||||
|
def test_playback_track_binds_sparse_map_increments_to_dense_camera_timeline(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
recording = tmp_path / "recording.rrd"
|
||||||
|
recording.write_bytes(b"sealed")
|
||||||
|
points = _TimedPoints(
|
||||||
|
times_ns=(10, 20),
|
||||||
|
values=(
|
||||||
|
np.asarray([[1.0, 2.0, 3.0]], dtype=np.float32),
|
||||||
|
np.asarray([[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], dtype=np.float32),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
empty_poses = _TimedPoses(times_ns=(), translations=(), quaternions_xyzw=())
|
||||||
|
index = _CanonicalSpatialIndex(
|
||||||
|
points=points,
|
||||||
|
poses=empty_poses,
|
||||||
|
trajectories=_TimedPoints(times_ns=(), values=()),
|
||||||
|
sensor_height_m=0.4,
|
||||||
|
sensor_height_sample_count=0,
|
||||||
|
sensor_height_mad_m=0.0,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(spatial_module, "_load_index", lambda *_args: index)
|
||||||
|
|
||||||
|
track, offsets = canonical_lab_spatial_playback_points(
|
||||||
|
recording,
|
||||||
|
"a" * 64,
|
||||||
|
(10, 15, 20, 25),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert offsets == (0, 1, 1, 3, 3)
|
||||||
|
assert track.tolist() == [
|
||||||
|
[1.0, 2.0, 3.0],
|
||||||
|
[4.0, 5.0, 6.0],
|
||||||
|
[7.0, 8.0, 9.0],
|
||||||
|
]
|
||||||
|
assert track.dtype == np.dtype("<f4")
|
||||||
|
assert not track.flags.writeable
|
||||||
@@ -104,3 +104,20 @@ def test_e4_class_fractions_use_only_valid_fov_pixels() -> None:
|
|||||||
|
|
||||||
assert {item["id"]: item["pixels"] for item in classes} == {1: 1, 4: 2, 7: 1}
|
assert {item["id"]: item["pixels"] for item in classes} == {1: 1, 4: 2, 7: 1}
|
||||||
assert sum(float(item["fraction_of_valid_fov"]) for item in classes) == 1.0
|
assert sum(float(item["fraction_of_valid_fov"]) for item in classes) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_e4_orchestrator_seals_a_single_decoder_gap_without_frame_shift() -> None:
|
||||||
|
path = (
|
||||||
|
Path(__file__).parents[1]
|
||||||
|
/ "experiments"
|
||||||
|
/ "perception"
|
||||||
|
/ "worker"
|
||||||
|
/ "Invoke-E4FullSessionSegmentation.ps1"
|
||||||
|
)
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
assert "-c:v h264_cuvid" in source
|
||||||
|
assert "-frame_pts 1" in source
|
||||||
|
assert '$decodedPath = Join-Path $decodedFramesRoot ("frame-{0}.png" -f $pts)' in source
|
||||||
|
assert "$repairs.Count -ge 1" in source
|
||||||
|
assert 'method = "duplicate-previous-decoded-frame"' in source
|
||||||
|
assert 'schema_version = "missioncore.recorded-video-decode-repair/v1"' in source
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from k1link.simulation.gaussian_pipeline_gateway import (
|
|||||||
GaussianPipelineGateway,
|
GaussianPipelineGateway,
|
||||||
GaussianPipelineGatewayError,
|
GaussianPipelineGatewayError,
|
||||||
GaussianPipelineIntegrityError,
|
GaussianPipelineIntegrityError,
|
||||||
|
GaussianPipelineUnavailableError,
|
||||||
_discover_bundle_members,
|
_discover_bundle_members,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,6 +257,30 @@ def test_gateway_surfaces_bounded_provider_rejection_detail(tmp_path: Path) -> N
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code", [502, 503, 504])
|
||||||
|
def test_gateway_classifies_temporary_provider_proxy_failures_as_unavailable(
|
||||||
|
tmp_path: Path,
|
||||||
|
status_code: int,
|
||||||
|
) -> None:
|
||||||
|
with (
|
||||||
|
GaussianPipelineGateway(
|
||||||
|
"http://gaussian.test",
|
||||||
|
_token_file(tmp_path),
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda _request: httpx.Response(
|
||||||
|
status_code,
|
||||||
|
json={"error": "provider_unavailable"},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
) as gateway,
|
||||||
|
pytest.raises(
|
||||||
|
GaussianPipelineUnavailableError,
|
||||||
|
match=rf"HTTP {status_code}.*provider_unavailable",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
gateway.capabilities()
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_rejects_incomplete_lcc_bundle(tmp_path: Path) -> None:
|
def test_gateway_rejects_incomplete_lcc_bundle(tmp_path: Path) -> None:
|
||||||
bundle = tmp_path / "bundle"
|
bundle = tmp_path / "bundle"
|
||||||
bundle.mkdir()
|
bundle.mkdir()
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ POWERSHELL_PATH = (
|
|||||||
/ "worker"
|
/ "worker"
|
||||||
/ "Invoke-LabV1VegetationGooseBenchmark.ps1"
|
/ "Invoke-LabV1VegetationGooseBenchmark.ps1"
|
||||||
)
|
)
|
||||||
|
RAV004_SOURCE_PATH = (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "perception"
|
||||||
|
/ "lab-v1-ravnoves004tree-full-video-source-v1.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_benchmark_contract_is_bounded_and_fail_closed() -> None:
|
def test_benchmark_contract_is_bounded_and_fail_closed() -> None:
|
||||||
@@ -93,3 +99,21 @@ def test_worker_wrapper_is_isolated_from_canonical_triton() -> None:
|
|||||||
assert '"--cap-drop", "ALL"' in source
|
assert '"--cap-drop", "ALL"' in source
|
||||||
assert '"--security-opt", "no-new-privileges"' in source
|
assert '"--security-opt", "no-new-privileges"' in source
|
||||||
assert "if ($canonicalAfter -ne $canonicalBefore)" in source
|
assert "if ($canonicalAfter -ne $canonicalBefore)" in source
|
||||||
|
|
||||||
|
|
||||||
|
def test_rav004_full_video_profile_and_decoder_gap_are_explicit() -> None:
|
||||||
|
profile = json.loads(RAV004_SOURCE_PATH.read_text(encoding="utf-8"))
|
||||||
|
source_profile = profile["source"]
|
||||||
|
assert profile["schema_version"] == "missioncore.lab-v1-ravnoves-source/v1"
|
||||||
|
assert source_profile["source_job_id"] == (
|
||||||
|
"recorded-camera-eb2783c5480d56bda07c8af0"
|
||||||
|
)
|
||||||
|
assert source_profile["expected_frame_count"] == 6830
|
||||||
|
assert source_profile["base_m4_result_id"] is None
|
||||||
|
source = POWERSHELL_PATH.read_text(encoding="utf-8")
|
||||||
|
assert "-c:v h264_cuvid" in source
|
||||||
|
assert 'schema_version = "missioncore.recorded-video-decode-repair/v1"' in source
|
||||||
|
assert 'method = "duplicate-previous-decoded-frame"' in source
|
||||||
|
assert "$repairs.Count -ge 1" in source
|
||||||
|
assert '& docker @arguments 2>&1 | ForEach-Object { Write-Output $_ }' in source
|
||||||
|
assert 'if ($dockerExitCode -ne 0)' in source
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
import k1link.sessions.media as recorded_media_module
|
import k1link.sessions.media as recorded_media_module
|
||||||
|
import k1link.web.session_api as session_api_module
|
||||||
from k1link.compute import RecordedPerceptionOverlayArtifact, RecordedPerceptionVideo
|
from k1link.compute import RecordedPerceptionOverlayArtifact, RecordedPerceptionVideo
|
||||||
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
|
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
|
||||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||||
@@ -458,6 +459,102 @@ def test_completed_recording_get_does_not_hold_delete_for_launch_lease(
|
|||||||
manager.close()
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_lab_spatial_frame_uses_ready_immutable_recording(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> 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))
|
||||||
|
payload = b"sealed-spatial-recording"
|
||||||
|
|
||||||
|
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
||||||
|
destination.write_bytes(payload)
|
||||||
|
return {
|
||||||
|
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||||
|
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"rrd_bytes": len(payload),
|
||||||
|
"timeline": "session_time",
|
||||||
|
"timeline_start_ns": 0,
|
||||||
|
"timeline_end_ns": 1_000_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
materializer = SessionRecordingMaterializer(store.data_dir, exporter=export_recording)
|
||||||
|
command = store.prepare_replay(session.name)
|
||||||
|
recording = materializer.materialize(command)
|
||||||
|
manager = SessionRecordingPreparationManager(materializer)
|
||||||
|
resolved = manager.resolve_cached(command)
|
||||||
|
assert resolved is not None and resolved.recording is not None
|
||||||
|
generation = hashlib.sha256(payload).hexdigest()
|
||||||
|
expected = {
|
||||||
|
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
|
||||||
|
"target_time_ns": 500_000_000,
|
||||||
|
"source_time_ns": 499_000_000,
|
||||||
|
"pose_time_ns": 499_000_000,
|
||||||
|
"trajectory_time_ns": 490_000_000,
|
||||||
|
"coordinate_frame": "body-ground",
|
||||||
|
"sensor_height": {
|
||||||
|
"meters": 0.32,
|
||||||
|
"source": "local-source-cloud-ground-quantile-median",
|
||||||
|
"sample_count": 20,
|
||||||
|
"mad_m": 0.03,
|
||||||
|
"authority": "visual-derived",
|
||||||
|
},
|
||||||
|
"spatial_profile": {
|
||||||
|
"profile_id": "source-paced-ground-v3",
|
||||||
|
"local_slam_history_seconds": 5.0,
|
||||||
|
"local_slam_radius_m": 30.0,
|
||||||
|
"local_slam_voxel_size_m": 0.12,
|
||||||
|
"local_slam_point_limit": 27000,
|
||||||
|
},
|
||||||
|
"body_frame": {
|
||||||
|
"origin_map_xyz_m": [0.0, 0.0, 0.0],
|
||||||
|
"sensor_origin_map_xyz_m": [0.0, 0.0, 0.32],
|
||||||
|
"basis_map_from_body": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||||
|
},
|
||||||
|
"source_point_count": 1,
|
||||||
|
"source_points_body_xyz_m": [[1.0, 2.0, 3.0]],
|
||||||
|
"local_slam_source_frame_count": 1,
|
||||||
|
"local_slam_source_point_count": 1,
|
||||||
|
"local_slam_point_count": 1,
|
||||||
|
"local_slam_body_xyz_m": [[0.0, 0.0, 0.0]],
|
||||||
|
}
|
||||||
|
|
||||||
|
def spatial_frame(path: Path, sha256: str, time_ns: int) -> dict[str, object]:
|
||||||
|
assert path == recording.path
|
||||||
|
assert sha256 == generation
|
||||||
|
assert time_ns == 500_000_000
|
||||||
|
return expected
|
||||||
|
|
||||||
|
monkeypatch.setattr(session_api_module, "canonical_lab_spatial_frame", spatial_frame)
|
||||||
|
router = build_session_router(
|
||||||
|
store,
|
||||||
|
recording_materializer=materializer,
|
||||||
|
recording_preparation_manager=manager,
|
||||||
|
)
|
||||||
|
spatial_route = endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame",
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = asyncio.run(spatial_route(
|
||||||
|
session_id=session.name,
|
||||||
|
generation=generation,
|
||||||
|
time_ns=500_000_000,
|
||||||
|
profile="source-paced-ground-v3",
|
||||||
|
))
|
||||||
|
assert json.loads(response.body) == expected
|
||||||
|
assert response.headers["etag"] == (
|
||||||
|
f'"{generation}:source-paced-ground-v3:499000000"'
|
||||||
|
)
|
||||||
|
assert response.headers["cache-control"].endswith("immutable")
|
||||||
|
finally:
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
|
def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Event
|
from threading import Event
|
||||||
|
from time import monotonic, sleep
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -444,6 +445,55 @@ def test_service_queue_processes_projects_strictly_one_at_a_time(tmp_path: Path)
|
|||||||
assert order == [projects[0]["project_id"], projects[1]["project_id"]]
|
assert order == [projects[0]["project_id"], projects[1]["project_id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_keeps_retained_source_queued_across_temporary_worker_outage(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
store = SimulationProjectStore(tmp_path)
|
||||||
|
project = store.create(
|
||||||
|
name="Reconnect without browser reupload",
|
||||||
|
scene_type="outdoor",
|
||||||
|
source_kind="folder",
|
||||||
|
files=_folder_files(),
|
||||||
|
)
|
||||||
|
_upload_all(store, project)
|
||||||
|
store.begin_build(project["project_id"])
|
||||||
|
|
||||||
|
class _ReconnectProvider(_ReadyProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.capability_calls = 0
|
||||||
|
|
||||||
|
def capabilities(self) -> dict[str, object]:
|
||||||
|
self.capability_calls += 1
|
||||||
|
if self.capability_calls == 1:
|
||||||
|
raise GaussianPipelineUnavailableError("temporary tunnel failure")
|
||||||
|
return super().capabilities()
|
||||||
|
|
||||||
|
provider = _ReconnectProvider()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"k1link.simulation.projects.PROVIDER_UNAVAILABLE_RETRY_LIMIT",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
service = SimulationProjectService(
|
||||||
|
store,
|
||||||
|
provider_factory=lambda: provider,
|
||||||
|
) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
service.enqueue(project["project_id"])
|
||||||
|
|
||||||
|
deadline = monotonic() + 1.0
|
||||||
|
while store.get(project["project_id"])["status"] != "ready" and monotonic() < deadline:
|
||||||
|
sleep(0.01)
|
||||||
|
recovered = store.get(project["project_id"])
|
||||||
|
assert recovered["status"] == "ready"
|
||||||
|
assert recovered["error"] is None
|
||||||
|
assert recovered["source"]["uploaded_byte_length"] == recovered["source"]["total_byte_length"]
|
||||||
|
assert provider.capability_calls == 2
|
||||||
|
assert provider.upload_calls == 1
|
||||||
|
assert provider.submit_calls == 1
|
||||||
|
|
||||||
|
|
||||||
def test_service_deletes_a_queued_project_before_worker_submission(tmp_path: Path) -> None:
|
def test_service_deletes_a_queued_project_before_worker_submission(tmp_path: Path) -> None:
|
||||||
store = SimulationProjectStore(tmp_path)
|
store = SimulationProjectStore(tmp_path)
|
||||||
project = store.create(
|
project = store.create(
|
||||||
@@ -561,6 +611,63 @@ def test_failed_project_retries_from_retained_source_and_releases_old_job(tmp_pa
|
|||||||
assert queued["source"]["uploaded_byte_length"] == queued["source"]["total_byte_length"]
|
assert queued["source"]["uploaded_byte_length"] == queued["source"]["total_byte_length"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_evicts_only_worker_backed_archive_staging_when_disk_is_low(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
store = SimulationProjectStore(tmp_path)
|
||||||
|
project = store.create(
|
||||||
|
name="Worker-backed archive",
|
||||||
|
scene_type="outdoor",
|
||||||
|
source_kind="archive",
|
||||||
|
files=[{"logical_path": "scene.rar", "byte_length": 6}],
|
||||||
|
)
|
||||||
|
source_file = project["source"]["files"][0]
|
||||||
|
store.append_upload(
|
||||||
|
project["project_id"],
|
||||||
|
source_file["file_id"],
|
||||||
|
offset=0,
|
||||||
|
payload=b"source",
|
||||||
|
)
|
||||||
|
store.begin_build(project["project_id"])
|
||||||
|
store.update_processing(
|
||||||
|
project["project_id"],
|
||||||
|
status="processing",
|
||||||
|
provider_job_id="gsp-20260826000000-deadbeef",
|
||||||
|
provider_state="ready",
|
||||||
|
)
|
||||||
|
service = SimulationProjectService(store, provider_factory=lambda: None)
|
||||||
|
source_path = store.source_root(project["project_id"]) / "scene.rar"
|
||||||
|
|
||||||
|
class _DiskUsage:
|
||||||
|
def __init__(self, free: int) -> None:
|
||||||
|
self.free = free
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"k1link.simulation.projects.shutil.disk_usage",
|
||||||
|
lambda _path: _DiskUsage(0 if source_path.exists() else 10 * 1024**3),
|
||||||
|
)
|
||||||
|
artifacts = [
|
||||||
|
{
|
||||||
|
"role": "preview",
|
||||||
|
"logical_path": "preview.sog",
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
"sha256": "a" * 64,
|
||||||
|
"byte_length": 1024,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
service._ensure_import_capacity(
|
||||||
|
project["project_id"],
|
||||||
|
artifacts,
|
||||||
|
store.artifacts_root(project["project_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not source_path.exists()
|
||||||
|
retained_metadata = store.get(project["project_id"])["source"]
|
||||||
|
assert retained_metadata["uploaded_byte_length"] == retained_metadata["total_byte_length"]
|
||||||
|
|
||||||
|
|
||||||
def test_failed_local_project_reattaches_to_live_provider_job_without_rebuild(
|
def test_failed_local_project_reattaches_to_live_provider_job_without_rebuild(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import hashlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import struct
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -20,11 +21,81 @@ from k1link.laboratory import LaboratoryEvidenceRegistry
|
|||||||
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
||||||
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
|
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
|
||||||
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
|
||||||
from k1link.web.vegetation_shadow_lab_api import build_vegetation_shadow_lab_router
|
from k1link.web.vegetation_shadow_lab_api import (
|
||||||
|
_canonical_route_playback_chunk_descriptor,
|
||||||
|
_mask_component_boxes,
|
||||||
|
_route_tgs_anchor_payload,
|
||||||
|
build_vegetation_shadow_lab_router,
|
||||||
|
)
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_component_boxes_keep_distinct_objects_separate() -> None:
|
||||||
|
mask = np.zeros((20, 30), dtype=np.uint8)
|
||||||
|
mask[2:10, 3:8] = 4
|
||||||
|
mask[4:12, 18:24] = 4
|
||||||
|
mask[15:17, 3:5] = 4
|
||||||
|
|
||||||
|
assert _mask_component_boxes(mask, 4, minimum_pixels=20) == [
|
||||||
|
(18, 4, 24, 12, 48),
|
||||||
|
(3, 2, 8, 10, 40),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None:
|
||||||
|
points = np.arange(18, dtype="<f4").reshape(6, 3)
|
||||||
|
descriptor = _canonical_route_playback_chunk_descriptor(
|
||||||
|
"/api/v1/laboratory/vegetation-shadow",
|
||||||
|
f"lab-v1-vegetation-shadow-{'a' * 64}",
|
||||||
|
memoryview(points).cast("B"),
|
||||||
|
(0, 1, 1, 3, 6),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert descriptor is not None
|
||||||
|
assert descriptor["start"] == 0
|
||||||
|
assert descriptor["count"] == 4
|
||||||
|
assert descriptor["point_count"] == 6
|
||||||
|
assert descriptor["bytes"] == points.nbytes
|
||||||
|
assert descriptor["shape"] == [6, 3]
|
||||||
|
assert len(str(descriptor["sha256"])) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "tgs-evidence.npz"
|
||||||
|
point_counts = np.arange(1, 11, dtype=np.int64)
|
||||||
|
offsets = np.concatenate(([0], np.cumsum(point_counts)))
|
||||||
|
points = np.arange(int(offsets[-1]) * 3, dtype=np.float32).reshape(-1, 3)
|
||||||
|
centers = np.arange(2244 * 2, dtype=np.float32).reshape(2244, 2) * 0.45
|
||||||
|
states = np.tile(np.arange(2244, dtype=np.uint16) % 4, (10, 1)).astype(np.uint8)
|
||||||
|
z_bounds = np.zeros((10, 2244, 2), dtype=np.float32)
|
||||||
|
z_bounds[..., 0] = np.nan
|
||||||
|
z_bounds[..., 1] = 1.25
|
||||||
|
np.savez(
|
||||||
|
path,
|
||||||
|
source_frame_indices=np.array(
|
||||||
|
[20, 408, 789, 1189, 1609, 1992, 2380, 3190, 4810, 6381],
|
||||||
|
dtype=np.int64,
|
||||||
|
),
|
||||||
|
current_increment_point_offsets=offsets,
|
||||||
|
current_increment_points_xyz_m=points,
|
||||||
|
costmap_cell_centers_xy_m=centers,
|
||||||
|
causal_rolling_1s_costmap_states=states,
|
||||||
|
causal_rolling_1s_costmap_z_bounds_m=z_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = _route_tgs_anchor_payload(path, 409)
|
||||||
|
|
||||||
|
assert payload["schema_version"] == "missioncore.lab-v1-route-tgs-anchor/v1"
|
||||||
|
assert payload["source_sequence"] == 409
|
||||||
|
assert payload["slot"] == 1
|
||||||
|
assert len(payload["current_points_xyz_m"]) == 2
|
||||||
|
assert len(payload["costmap"]["centers_xy_m"]) == 2244
|
||||||
|
assert set(payload["costmap"]["state_codes"]) == {0, 1, 2, 3}
|
||||||
|
assert payload["costmap"]["z_bounds_m"][0] == [None, 1.25]
|
||||||
|
|
||||||
|
|
||||||
def test_coarse_policy_masks_mark_every_outside_fov_pixel_undefined(
|
def test_coarse_policy_masks_mark_every_outside_fov_pixel_undefined(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -270,6 +341,90 @@ def test_vegetation_shadow_lab_seals_autonomous_visual_evidence(
|
|||||||
assert mask.content == b"\x89PNG\r\n\x1a\n"
|
assert mask.content == b"\x89PNG\r\n\x1a\n"
|
||||||
assert mask.headers["cache-control"].endswith("immutable")
|
assert mask.headers["cache-control"].endswith("immutable")
|
||||||
|
|
||||||
|
full_archive_payloads = (b"\x89PNG\r\n\x1a\ncity", b"\x89PNG\r\n\x1a\nvegetation")
|
||||||
|
full_timeline_payload = struct.pack("<2Q", 1_000_000_000, 1_100_000_000)
|
||||||
|
full_identity = dict(manifest["identity"])
|
||||||
|
full_route = {
|
||||||
|
"frame_count": 2,
|
||||||
|
"timeline": {
|
||||||
|
"path": "video/frame-source-times-ns.bin",
|
||||||
|
"sha256": hashlib.sha256(full_timeline_payload).hexdigest(),
|
||||||
|
"byte_length": len(full_timeline_payload),
|
||||||
|
"encoding": "uint64-le-nanoseconds",
|
||||||
|
"frame_count": 2,
|
||||||
|
},
|
||||||
|
"layers": {
|
||||||
|
layer: {"mask_archive": {"path": "video/full-route-masks.zip"}}
|
||||||
|
for layer in ("city", "vegetation")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
full_identity["route_full_review"] = full_route
|
||||||
|
full_identity_sha = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
full_identity,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
full_result_id = f"lab-v1-vegetation-shadow-{full_identity_sha}"
|
||||||
|
full_root = result_root.parent / full_result_id
|
||||||
|
shutil.copytree(result_root, full_root)
|
||||||
|
full_archive = full_root / "video" / "full-route-masks.zip"
|
||||||
|
full_archive.parent.mkdir(exist_ok=True)
|
||||||
|
with zipfile.ZipFile(full_archive, "x", compression=zipfile.ZIP_STORED) as frozen:
|
||||||
|
for sequence, payload in enumerate(full_archive_payloads, start=1):
|
||||||
|
frozen.writestr(f"masks/frame-{sequence:06d}.png", payload)
|
||||||
|
full_timeline = full_root / "video" / "frame-source-times-ns.bin"
|
||||||
|
full_timeline.write_bytes(full_timeline_payload)
|
||||||
|
full_manifest = dict(manifest)
|
||||||
|
full_manifest["result_id"] = full_result_id
|
||||||
|
full_manifest["identity"] = full_identity
|
||||||
|
full_manifest["identity_sha256"] = full_identity_sha
|
||||||
|
full_manifest["route_full_review"] = full_route
|
||||||
|
full_manifest["artifacts"] = [
|
||||||
|
*manifest["artifacts"],
|
||||||
|
{
|
||||||
|
"role": "full-route-mask-fixture",
|
||||||
|
"path": "video/full-route-masks.zip",
|
||||||
|
"byte_length": full_archive.stat().st_size,
|
||||||
|
"sha256": _sha256(full_archive),
|
||||||
|
"media_type": "application/zip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "full-route-frame-timeline",
|
||||||
|
"path": "video/frame-source-times-ns.bin",
|
||||||
|
"byte_length": full_timeline.stat().st_size,
|
||||||
|
"sha256": _sha256(full_timeline),
|
||||||
|
"media_type": "application/octet-stream",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
(full_root / "result.json").write_text(
|
||||||
|
json.dumps(full_manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
for layer, sequence, expected in (
|
||||||
|
("city", 0, full_archive_payloads[0]),
|
||||||
|
("vegetation", 1, full_archive_payloads[1]),
|
||||||
|
):
|
||||||
|
response = client.get(
|
||||||
|
f"/api/v1/laboratory/vegetation-shadow/{full_result_id}"
|
||||||
|
f"/route-masks/{layer}/{sequence}"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content == expected
|
||||||
|
assert response.headers["cache-control"].endswith("immutable")
|
||||||
|
assert client.get(
|
||||||
|
f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-masks/city/2"
|
||||||
|
).status_code == 404
|
||||||
|
timeline = client.get(
|
||||||
|
f"/api/v1/laboratory/vegetation-shadow/{full_result_id}/route-timeline"
|
||||||
|
)
|
||||||
|
assert timeline.status_code == 200
|
||||||
|
assert timeline.content == full_timeline_payload
|
||||||
|
assert timeline.headers["cache-control"].endswith("immutable")
|
||||||
|
|
||||||
(result_root / asset_path).write_bytes(b"tampered")
|
(result_root / asset_path).write_bytes(b"tampered")
|
||||||
assert (
|
assert (
|
||||||
client.get(f"/api/v1/laboratory/vegetation-shadow/{result_root.name}").status_code
|
client.get(f"/api/v1/laboratory/vegetation-shadow/{result_root.name}").status_code
|
||||||
|
|||||||
Reference in New Issue
Block a user