feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
This commit is contained in:
parent
ada2a55ee6
commit
b53d6d5a45
|
|
@ -9,4 +9,4 @@
|
|||
*.xbin binary
|
||||
*.las binary
|
||||
*.lcc binary
|
||||
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ __pycache__/
|
|||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
apps/control-station/dist-shadow/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -158,13 +158,16 @@ local reception; plugin-commanded v0.5.0 acquisition uses the separately gated
|
|||
canonical K1 START/STOP dialogue.
|
||||
|
||||
Plugin v0.6.0 makes the local connection direction explicit. Bridge remains the
|
||||
default; Direct Connect sends the same reviewed BLE frame with credentials for
|
||||
an already-running controller hotspot; Quick Connect sends no BLE write and
|
||||
instead associates this Mac with the K1 AP once. Each mode has a distinct
|
||||
topology attestation and no automatic retry. The Bridge path is physically
|
||||
accepted. Direct Connect and the Mission Core Quick Connect host adapter are
|
||||
implemented and offline-verified but still require their own owner-operated
|
||||
physical acceptance cycles; see [ADR 0013](docs/adr/0013-k1-local-connection-matrix.md).
|
||||
default and accepted product path; Direct Connect sends the reviewed station
|
||||
provisioning frame for an already-running controller hotspot. Quick Connect
|
||||
sends one separately reviewed AP-enable frame and can associate a prepared Mac
|
||||
through CoreWLAN. Its device activation and prepared-host association were
|
||||
physically accepted, but credential bootstrap is not portable: a clean host
|
||||
cannot acquire the firmware-defined AP material from the reviewed BLE protocol.
|
||||
Quick Connect therefore remains laboratory functionality, with no automatic
|
||||
firmware download, iPhone extraction or hard-coded fallback. Each mode has a
|
||||
distinct topology attestation and no automatic retry; see
|
||||
[ADR 0013](docs/adr/0013-k1-local-connection-matrix.md).
|
||||
|
||||
The exact recovered `ModelingRequest` start/stop encoder, response correlator
|
||||
and device-status state machine are installed in the operator-gated interactive
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
||||
|
||||
const sha256File = (path) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
|
||||
if (manifest.version !== "0.34.1") {
|
||||
throw new Error(
|
||||
|
|
@ -12,6 +17,51 @@ if (manifest.version !== "0.34.1") {
|
|||
);
|
||||
}
|
||||
|
||||
const vendorRuntime = [
|
||||
{
|
||||
label: "wasm runtime",
|
||||
packagePath: resolve(packageRoot, "re_viewer_bg.wasm"),
|
||||
vendorPath: resolve(vendorRoot, "re_viewer_bg.nodedc.wasm"),
|
||||
publishedSha256: "3fe7aab8ea6bb0fd03c3ef694932943411ee174029e398c539c390beb0824d35",
|
||||
previousNodedcSha256: "1c25e8cecd7641e6f8044d00a6a1cf9d08a615b6f8737026c7d93623b3e06ee7",
|
||||
nodedcSha256: "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb",
|
||||
},
|
||||
{
|
||||
label: "wasm JavaScript glue",
|
||||
packagePath: resolve(packageRoot, "re_viewer.js"),
|
||||
vendorPath: resolve(vendorRoot, "re_viewer.nodedc.js"),
|
||||
publishedSha256: "7c4ba900820137a6ba23e0f7f57d56e3b007db0de3825d5c8bf0d7684b3cc6a6",
|
||||
nodedcSha256: "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339",
|
||||
},
|
||||
];
|
||||
|
||||
for (const runtime of vendorRuntime) {
|
||||
const vendorSha256 = sha256File(runtime.vendorPath);
|
||||
if (vendorSha256 !== runtime.nodedcSha256) {
|
||||
throw new Error(
|
||||
`Refusing corrupt NODE.DC Rerun ${runtime.label}: ${vendorSha256} != ${runtime.nodedcSha256}`,
|
||||
);
|
||||
}
|
||||
|
||||
const installedSha256 = sha256File(runtime.packagePath);
|
||||
if (
|
||||
![
|
||||
runtime.publishedSha256,
|
||||
runtime.previousNodedcSha256,
|
||||
runtime.nodedcSha256,
|
||||
].includes(installedSha256)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to replace unexpected Rerun ${runtime.label}: ${installedSha256}`,
|
||||
);
|
||||
}
|
||||
|
||||
copyFileSync(runtime.vendorPath, runtime.packagePath);
|
||||
if (sha256File(runtime.packagePath) !== runtime.nodedcSha256) {
|
||||
throw new Error(`NODE.DC Rerun ${runtime.label} verification failed after copy`);
|
||||
}
|
||||
}
|
||||
|
||||
const patches = [
|
||||
{
|
||||
label: "compiled watchdog",
|
||||
|
|
@ -57,5 +107,5 @@ for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "inde
|
|||
}
|
||||
|
||||
console.log(
|
||||
"Patched @rerun-io/web-viewer 0.34.1 watchdog teardown and singleton global keyup listener.",
|
||||
"Patched @rerun-io/web-viewer 0.34.1 native zoom-to-cursor, watchdog teardown, and singleton global keyup listener.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const [inputArgument, outputArgument] = process.argv.slice(2);
|
||||
if (!inputArgument || !outputArgument) {
|
||||
throw new Error("Usage: node transform-rerun-web-viewer-glue.mjs INPUT OUTPUT");
|
||||
}
|
||||
|
||||
const inputPath = resolve(inputArgument);
|
||||
const outputPath = resolve(outputArgument);
|
||||
const expectedGeneratedSha256 =
|
||||
"cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874";
|
||||
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
let code = readFileSync(inputPath, "utf8");
|
||||
|
||||
if (sha256(code) !== expectedGeneratedSha256) {
|
||||
throw new Error(`Refusing to transform unexpected wasm-bindgen output: ${inputPath}`);
|
||||
}
|
||||
|
||||
// This is the same no-modules-base transformation used by Rerun 0.34.1's
|
||||
// rerun_js/web-viewer/build-wasm.mjs. Each factory call gets isolated closure state.
|
||||
const wrapperStart = "let wasm_bindgen = (function(exports) {";
|
||||
const wrapperEnd = `return Object.assign(__wbg_init, { initSync }, exports);
|
||||
})({ __proto__: null });`;
|
||||
|
||||
if (!code.includes(wrapperStart) || !code.includes(wrapperEnd)) {
|
||||
throw new Error("Rerun wasm-bindgen wrapper markers no longer match");
|
||||
}
|
||||
code = code.replace(wrapperStart, "").replace(wrapperEnd, "");
|
||||
|
||||
code = `
|
||||
export default function() {
|
||||
const exports = { __proto__: null };
|
||||
${code}
|
||||
|
||||
function deinit() {
|
||||
__wbg_init.__wbindgen_wasm_module = null;
|
||||
wasmModule = null;
|
||||
wasm = null;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedFloat32ArrayMemory0 = null;
|
||||
cachedInt16ArrayMemory0 = null;
|
||||
cachedInt32ArrayMemory0 = null;
|
||||
cachedInt8ArrayMemory0 = null;
|
||||
cachedUint16ArrayMemory0 = null;
|
||||
cachedUint32ArrayMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
}
|
||||
|
||||
return Object.assign(__wbg_init, { initSync, deinit }, exports);
|
||||
}
|
||||
`;
|
||||
|
||||
const closureDtorsOriginal = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => wasm.__wbindgen_destroy_closure(state.a, state.b));`;
|
||||
const closureDtorsPatch = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => {
|
||||
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
});`;
|
||||
|
||||
if (!code.includes(closureDtorsOriginal)) {
|
||||
throw new Error("Rerun CLOSURE_DTORS block no longer matches");
|
||||
}
|
||||
code = code.replace(closureDtorsOriginal, closureDtorsPatch);
|
||||
|
||||
const makeMutClosureOriginal = `function makeMutClosure(arg0, arg1, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1 };
|
||||
const real = (...args) => {
|
||||
|
||||
// First up with a closure we increment the internal reference
|
||||
// count. This ensures that the Rust closure environment won't
|
||||
// be deallocated while we're invoking it.
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}`;
|
||||
const makeMutClosurePatch = `function makeMutClosure(arg0, arg1, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1 };
|
||||
const real = (...args) => {
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
if (!wasm) return;
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
state.a = a;
|
||||
real._wbg_cb_unref();
|
||||
}
|
||||
};
|
||||
real._wbg_cb_unref = () => {
|
||||
if (--state.cnt === 0) {
|
||||
if (wasm) wasm.__wbindgen_destroy_closure(state.a, state.b);
|
||||
state.a = 0;
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
}
|
||||
};
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}`;
|
||||
|
||||
if (!code.includes(makeMutClosureOriginal)) {
|
||||
throw new Error("Rerun makeMutClosure block no longer matches");
|
||||
}
|
||||
code = code.replace(makeMutClosureOriginal, makeMutClosurePatch);
|
||||
|
||||
writeFileSync(outputPath, code);
|
||||
console.log(`Transformed Rerun wasm glue: ${sha256(code)} ${outputPath}`);
|
||||
|
|
@ -88,7 +88,22 @@ const paletteOptions: Array<{ value: PointPalette; label: string; description: s
|
|||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
||||
];
|
||||
|
||||
function toViewerSettings(settings: SceneSettings): ViewerSettings {
|
||||
interface LivePerceptionLayers {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
}
|
||||
|
||||
const defaultLivePerceptionLayers: LivePerceptionLayers = {
|
||||
detections2d: false,
|
||||
segmentation: false,
|
||||
cuboids3d: false,
|
||||
};
|
||||
|
||||
function toViewerSettings(
|
||||
settings: SceneSettings,
|
||||
perception: LivePerceptionLayers = defaultLivePerceptionLayers,
|
||||
): ViewerSettings {
|
||||
return {
|
||||
point_size: settings.pointSize,
|
||||
color_mode: settings.colorMode,
|
||||
|
|
@ -98,6 +113,9 @@ function toViewerSettings(settings: SceneSettings): ViewerSettings {
|
|||
show_points: settings.showPoints,
|
||||
show_trajectory: settings.showTrajectory,
|
||||
show_grid: settings.showGrid,
|
||||
show_detections_2d: perception.detections2d,
|
||||
show_segmentation: perception.segmentation,
|
||||
show_cuboids_3d: perception.cuboids3d,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +167,9 @@ export default function App() {
|
|||
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
|
||||
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
|
||||
const [livePerceptionLayers, setLivePerceptionLayers] = useState<LivePerceptionLayers>(
|
||||
defaultLivePerceptionLayers,
|
||||
);
|
||||
const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state);
|
||||
const sourceSwitchBlockedReason = sourceSwitchBlocked
|
||||
? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON
|
||||
|
|
@ -161,6 +182,8 @@ export default function App() {
|
|||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||
const livePerceptionLayersRef = useRef<LivePerceptionLayers>(defaultLivePerceptionLayers);
|
||||
const livePerceptionRevisionRef = useRef(0);
|
||||
const replayActiveRef = useRef(false);
|
||||
const sceneSettingsCommitterActiveRef = useRef(true);
|
||||
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
||||
|
|
@ -197,7 +220,9 @@ export default function App() {
|
|||
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
|
||||
commit: (settings) => replayActiveRef.current
|
||||
? Promise.resolve(true)
|
||||
: runtimeUpdateViewerSettingsRef.current(toViewerSettings(settings)),
|
||||
: runtimeUpdateViewerSettingsRef.current(
|
||||
toViewerSettings(settings, livePerceptionLayersRef.current),
|
||||
),
|
||||
onSettled: ({ value, applied, superseded }) => {
|
||||
if (!sceneSettingsCommitterActiveRef.current) return;
|
||||
if (applied) confirmedSceneSettingsRef.current = value;
|
||||
|
|
@ -275,6 +300,13 @@ export default function App() {
|
|||
sceneSettingsCommitterRef.current?.isBusy()
|
||||
) return;
|
||||
const merged = mergeViewerSettings(sceneSettingsRef.current, remote);
|
||||
const remoteLayers = {
|
||||
detections2d: remote.show_detections_2d ?? false,
|
||||
segmentation: remote.show_segmentation ?? false,
|
||||
cuboids3d: remote.show_cuboids_3d ?? false,
|
||||
};
|
||||
livePerceptionLayersRef.current = remoteLayers;
|
||||
setLivePerceptionLayers(remoteLayers);
|
||||
sceneSettingsRef.current = merged;
|
||||
confirmedSceneSettingsRef.current = merged;
|
||||
setSceneSettings(merged);
|
||||
|
|
@ -334,7 +366,9 @@ export default function App() {
|
|||
return;
|
||||
}
|
||||
appliedProfileKeyRef.current = applicationKey;
|
||||
void runtime.updateViewerSettings(toViewerSettings(profile.sceneSettings)).then((applied) => {
|
||||
void runtime.updateViewerSettings(
|
||||
toViewerSettings(profile.sceneSettings, livePerceptionLayersRef.current),
|
||||
).then((applied) => {
|
||||
if (!applied && appliedProfileKeyRef.current === applicationKey) {
|
||||
appliedProfileKeyRef.current = null;
|
||||
}
|
||||
|
|
@ -441,6 +475,21 @@ export default function App() {
|
|||
activateSceneWindow("layers");
|
||||
};
|
||||
|
||||
const changeLivePerceptionLayers = useCallback((next: LivePerceptionLayers) => {
|
||||
const previous = livePerceptionLayersRef.current;
|
||||
const revision = ++livePerceptionRevisionRef.current;
|
||||
livePerceptionLayersRef.current = next;
|
||||
setLivePerceptionLayers(next);
|
||||
if (replayActiveRef.current) return;
|
||||
void runtimeUpdateViewerSettingsRef.current(
|
||||
toViewerSettings(sceneSettingsRef.current, next),
|
||||
).then((applied) => {
|
||||
if (applied || livePerceptionRevisionRef.current !== revision) return;
|
||||
livePerceptionLayersRef.current = previous;
|
||||
setLivePerceptionLayers(previous);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const beginRecordedReplaySwitch = useCallback(async () => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
|
|
@ -706,6 +755,8 @@ export default function App() {
|
|||
onAccumulationChange={(accumulationSeconds) =>
|
||||
stageDisplayPatch({ accumulationSeconds })}
|
||||
onAccumulationCommit={flushDisplaySettings}
|
||||
livePerceptionLayers={livePerceptionLayers}
|
||||
onLivePerceptionLayersChange={changeLivePerceptionLayers}
|
||||
observationLayout={observationLayout}
|
||||
spatialControls={selection?.SpatialControlsView
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export function FloatingObservationWindow({
|
|||
rect,
|
||||
maximized,
|
||||
active,
|
||||
hidden = false,
|
||||
onRectChange,
|
||||
onMaximizedChange,
|
||||
onActivate,
|
||||
|
|
@ -89,6 +90,7 @@ export function FloatingObservationWindow({
|
|||
rect?: ObservationWindowRect;
|
||||
maximized: boolean;
|
||||
active: boolean;
|
||||
hidden?: boolean;
|
||||
onRectChange: (rect: ObservationWindowRect) => void;
|
||||
onMaximizedChange: (maximized: boolean) => void;
|
||||
onActivate: () => void;
|
||||
|
|
@ -153,7 +155,7 @@ export function FloatingObservationWindow({
|
|||
resizable={source.capabilities.resizable}
|
||||
active={active}
|
||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
||||
className="floating-observation-window"
|
||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -52,18 +52,17 @@ const preparationLabel: Record<ObservationPreparationPhase, string> = {
|
|||
requesting: "Запрашиваем подготовку",
|
||||
queued: "В очереди",
|
||||
validating: "Проверяем запись",
|
||||
exporting: "Готовим облако точек",
|
||||
exporting: "Готовим операторскую сцену",
|
||||
finalizing: "Завершаем подготовку",
|
||||
failed: "Подготовка не выполнена",
|
||||
cancelled: "Подготовка отменена",
|
||||
};
|
||||
|
||||
function progressCopy(
|
||||
phase: ObservationPreparationPhase,
|
||||
progress: number | null,
|
||||
): string {
|
||||
const label = preparationLabel[phase];
|
||||
return progress === null ? label : `${label} · ${Math.round(progress * 100)}%`;
|
||||
function progressCopy(phase: ObservationPreparationPhase): string {
|
||||
// Backend progress values are phase markers and heartbeat revisions, not a
|
||||
// measured fraction of bytes or frames. Presenting 0.5 as "50%" made a
|
||||
// healthy long export look stalled, especially after a browser reload.
|
||||
return preparationLabel[phase];
|
||||
}
|
||||
|
||||
function formatStartedAt(value: string): string {
|
||||
|
|
@ -126,7 +125,7 @@ export function ObservationSessionSelect({
|
|||
onReplaySettled,
|
||||
});
|
||||
const triggerCopy = sessions.replayProgress
|
||||
? progressCopy(sessions.replayProgress.phase, sessions.replayProgress.progress)
|
||||
? progressCopy(sessions.replayProgress.phase)
|
||||
: sessions.state === "loading"
|
||||
? "Загружаем сессии…"
|
||||
: "Сохранённые сессии";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { bytesToHex } from "@noble/hashes/utils.js";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
|
|
@ -11,7 +9,6 @@ import {
|
|||
type ObservationSessionFetch,
|
||||
} from "../core/observation/sessionArchive";
|
||||
import {
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
type RecordedAdmissionPhase,
|
||||
type RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
|
|
@ -22,32 +19,11 @@ export interface RecordedObservationPlayback {
|
|||
playing: boolean;
|
||||
}
|
||||
|
||||
export interface RecordedMediaLoadProgress {
|
||||
loadedBytes: number;
|
||||
totalBytes: number;
|
||||
loadedParts: number;
|
||||
totalParts: number;
|
||||
}
|
||||
|
||||
interface VerifiedRecordedMediaEpoch {
|
||||
descriptor: ObservationRecordedMediaEpoch;
|
||||
readonly init: ArrayBuffer;
|
||||
readonly segments: readonly ArrayBuffer[];
|
||||
}
|
||||
|
||||
export interface VerifiedRecordedMediaArchive {
|
||||
export interface RecordedMediaArchive {
|
||||
manifest: ObservationRecordedMediaManifest;
|
||||
epochs: readonly VerifiedRecordedMediaEpoch[];
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
interface RecordedMediaBinaryDescriptor {
|
||||
url: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
accept: string;
|
||||
}
|
||||
|
||||
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
|
||||
|
||||
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
|
||||
|
|
@ -134,205 +110,33 @@ function sourceContract(source: ObservationSourceDescriptor): ObservationRecorde
|
|||
};
|
||||
}
|
||||
|
||||
function expectedPayloadEtag(digest: string): string {
|
||||
return `"sha256:${digest}"`;
|
||||
}
|
||||
|
||||
export async function fetchVerifiedRecordedMediaBytes(
|
||||
descriptor: RecordedMediaBinaryDescriptor,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
|
||||
): Promise<ArrayBuffer> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(descriptor.url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: descriptor.accept,
|
||||
"If-Match": expectedPayloadEtag(descriptor.sha256),
|
||||
},
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservationSessionContractError(
|
||||
"Не удалось загрузить канонический фрагмент записанного видео.",
|
||||
);
|
||||
}
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Фрагмент записанного видео вернул HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
if (response.headers.get("ETag") !== expectedPayloadEtag(descriptor.sha256)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Фрагмент записанного видео не соответствует immutable manifest.",
|
||||
);
|
||||
}
|
||||
const contentLength = response.headers.get("Content-Length");
|
||||
if (
|
||||
contentLength === null ||
|
||||
!/^[1-9][0-9]*$/.test(contentLength) ||
|
||||
Number(contentLength) !== descriptor.byteLength
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Длина фрагмента записанного видео не соответствует immutable manifest.",
|
||||
);
|
||||
}
|
||||
const payload = await response.arrayBuffer();
|
||||
if (payload.byteLength !== descriptor.byteLength) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Фрагмент записанного видео был усечён во время передачи.",
|
||||
);
|
||||
}
|
||||
const digest = bytesToHex(sha256(new Uint8Array(payload)));
|
||||
if (digest !== descriptor.sha256) {
|
||||
throw new ObservationSessionContractError(
|
||||
"SHA-256 фрагмента записанного видео не совпадает с immutable manifest.",
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function manifestIdentity(manifest: ObservationRecordedMediaManifest): string {
|
||||
return JSON.stringify(manifest);
|
||||
}
|
||||
|
||||
export async function fetchVerifiedRecordedMediaArchive(
|
||||
export async function fetchRecordedMediaArchive(
|
||||
source: ObservationRecordedMediaSource,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
onProgress,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
onProgress?: (progress: RecordedMediaLoadProgress) => void;
|
||||
} = {},
|
||||
): Promise<VerifiedRecordedMediaArchive> {
|
||||
): Promise<RecordedMediaArchive> {
|
||||
const manifest = await fetchObservationRecordedMediaManifest(source, {
|
||||
signal,
|
||||
fetcher,
|
||||
expectedGenerationSha256: source.manifestGenerationSha256,
|
||||
});
|
||||
const totalBytes = manifest.epochs.reduce(
|
||||
(archiveTotal, epoch) => archiveTotal + epoch.initByteLength + epoch.segments.reduce(
|
||||
(epochTotal, segment) => epochTotal + segment.byteLength,
|
||||
0,
|
||||
),
|
||||
0,
|
||||
);
|
||||
const totalParts = manifest.epochs.reduce(
|
||||
(total, epoch) => total + 1 + epoch.segments.length,
|
||||
0,
|
||||
);
|
||||
const totalBytes = manifest.epochs.reduce((total, epoch) => total + epoch.byteLength, 0);
|
||||
if (
|
||||
!Number.isSafeInteger(totalBytes) ||
|
||||
totalBytes < 1 ||
|
||||
totalBytes > MAX_RECORDED_MEDIA_SOURCE_BYTES ||
|
||||
totalBytes !== manifest.byteLength ||
|
||||
totalBytes !== source.byteLength
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Размер записанного медиаканала выходит за безопасный лимит браузера.",
|
||||
"Размеры потоков записанного медиаканала не совпадают с manifest.",
|
||||
);
|
||||
}
|
||||
let loadedBytes = 0;
|
||||
let loadedParts = 0;
|
||||
const publishProgress = () => onProgress?.({
|
||||
loadedBytes,
|
||||
totalBytes,
|
||||
loadedParts,
|
||||
totalParts,
|
||||
});
|
||||
publishProgress();
|
||||
|
||||
const epochs: VerifiedRecordedMediaEpoch[] = [];
|
||||
for (const epoch of manifest.epochs) {
|
||||
const init = await fetchVerifiedRecordedMediaBytes({
|
||||
url: epoch.initUrl,
|
||||
byteLength: epoch.initByteLength,
|
||||
sha256: epoch.initSha256,
|
||||
accept: "video/mp4",
|
||||
}, { signal, fetcher });
|
||||
loadedBytes += init.byteLength;
|
||||
loadedParts += 1;
|
||||
publishProgress();
|
||||
|
||||
const segments: ArrayBuffer[] = [];
|
||||
for (const segment of epoch.segments) {
|
||||
const payload = await fetchVerifiedRecordedMediaBytes({
|
||||
url: segment.url,
|
||||
byteLength: segment.byteLength,
|
||||
sha256: segment.sha256,
|
||||
accept: "video/iso.segment",
|
||||
}, { signal, fetcher });
|
||||
segments.push(payload);
|
||||
loadedBytes += payload.byteLength;
|
||||
loadedParts += 1;
|
||||
publishProgress();
|
||||
}
|
||||
epochs.push({ descriptor: epoch, init, segments });
|
||||
}
|
||||
|
||||
// Bind the complete byte set to the same manifest generation at both ends
|
||||
// of the transfer. A replacement during a long camera download fails closed.
|
||||
const confirmed = await fetchObservationRecordedMediaManifest(source, {
|
||||
signal,
|
||||
fetcher,
|
||||
expectedGenerationSha256: manifest.generationSha256,
|
||||
});
|
||||
if (manifestIdentity(confirmed) !== manifestIdentity(manifest)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Manifest записанного видео изменился во время полной загрузки.",
|
||||
);
|
||||
}
|
||||
if (loadedBytes !== source.byteLength) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Полная загрузка камеры не совпала с launch byte_length.",
|
||||
);
|
||||
}
|
||||
return { manifest, epochs, byteLength: loadedBytes };
|
||||
}
|
||||
|
||||
export function appendRecordedMediaBuffer(
|
||||
sourceBuffer: SourceBuffer,
|
||||
payload: ArrayBuffer,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onUpdateEnd = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
cleanup();
|
||||
reject(new Error("SourceBuffer rejected archived fMP4 data"));
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanup = () => {
|
||||
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
|
||||
sourceBuffer.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
|
||||
sourceBuffer.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
try {
|
||||
sourceBuffer.appendBuffer(payload);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
return { manifest, byteLength: totalBytes };
|
||||
}
|
||||
|
||||
function videoHasSeekableArchive(
|
||||
|
|
@ -358,17 +162,22 @@ function waitForSeekableArchive(
|
|||
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera did not become seekable"));
|
||||
}, 20_000);
|
||||
let stallTimer: ReturnType<typeof globalThis.setTimeout> | undefined;
|
||||
const armStallTimer = () => {
|
||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||
stallTimer = globalThis.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Archived camera stream stalled"));
|
||||
}, 45_000);
|
||||
};
|
||||
const cleanup = () => {
|
||||
globalThis.clearTimeout(timeout);
|
||||
if (stallTimer !== undefined) globalThis.clearTimeout(stallTimer);
|
||||
for (const event of events) video.removeEventListener(event, onProgress);
|
||||
video.removeEventListener("error", onError);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onProgress = () => {
|
||||
armStallTimer();
|
||||
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
|
|
@ -384,43 +193,30 @@ function waitForSeekableArchive(
|
|||
for (const event of events) video.addEventListener(event, onProgress);
|
||||
video.addEventListener("error", onError, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
armStallTimer();
|
||||
});
|
||||
}
|
||||
|
||||
async function mountVerifiedRecordedEpoch(
|
||||
async function mountRecordedEpochStream(
|
||||
video: HTMLVideoElement,
|
||||
epoch: VerifiedRecordedMediaEpoch,
|
||||
descriptor: ObservationRecordedMediaEpoch,
|
||||
signal: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
const descriptor = epoch.descriptor;
|
||||
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
|
||||
throw new Error("Archived camera codec is not supported");
|
||||
}
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// A sealed fragmented-MP4 generation is already complete and immutable.
|
||||
// Present it as one Blob so Chromium can index and seek the whole archive
|
||||
// without retaining the same 166+ MiB epoch in a quota-limited MSE
|
||||
// SourceBuffer. Every constituent byte was fetched and SHA-256 verified
|
||||
// before this boundary, and Blob preserves their canonical order.
|
||||
const payload = new Blob([epoch.init, ...epoch.segments], {
|
||||
type: descriptor.mediaType,
|
||||
});
|
||||
const expectedByteLength = epoch.init.byteLength + epoch.segments.reduce(
|
||||
(total, segment) => total + segment.byteLength,
|
||||
0,
|
||||
);
|
||||
if (payload.size !== expectedByteLength) {
|
||||
throw new Error("Archived camera Blob is incomplete");
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(payload);
|
||||
// The generation token binds the native media request to the exact manifest.
|
||||
// The browser range-streams the virtual init+fragment file from the server;
|
||||
// no complete camera archive is copied into JavaScript memory.
|
||||
const cleanup = () => {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
video.src = objectUrl;
|
||||
video.preload = "auto";
|
||||
video.src = descriptor.streamUrl;
|
||||
video.load();
|
||||
try {
|
||||
await waitForSeekableArchive(
|
||||
|
|
@ -483,22 +279,15 @@ export function RecordedFmp4Player({
|
|||
recordedDelivery?.timelineEndSeconds,
|
||||
],
|
||||
);
|
||||
const [archive, setArchive] = useState<VerifiedRecordedMediaArchive | null>(null);
|
||||
const [archive, setArchive] = useState<RecordedMediaArchive | null>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<RecordedMediaLoadProgress | null>(null);
|
||||
const [bufferRevision, setBufferRevision] = useState(0);
|
||||
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
|
||||
const epoch = useMemo(
|
||||
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
|
||||
[archive?.manifest.epochs, currentSeconds],
|
||||
);
|
||||
const verifiedEpoch = useMemo(
|
||||
() => epoch
|
||||
? archive?.epochs.find(({ descriptor }) => descriptor.ordinal === epoch.ordinal) ?? null
|
||||
: null,
|
||||
[archive?.epochs, epoch],
|
||||
);
|
||||
const waitingForEpoch = Boolean(archive && !epoch);
|
||||
const selectedGeneration = contract && epoch
|
||||
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
|
||||
|
|
@ -514,7 +303,6 @@ export function RecordedFmp4Player({
|
|||
useEffect(() => {
|
||||
if (!contract) {
|
||||
setArchive(null);
|
||||
setProgress(null);
|
||||
setReadyGeneration(null);
|
||||
setState("error");
|
||||
reportAdmission({
|
||||
|
|
@ -527,7 +315,6 @@ export function RecordedFmp4Player({
|
|||
if (!prepare) return;
|
||||
const abort = new AbortController();
|
||||
setArchive(null);
|
||||
setProgress(null);
|
||||
setReadyGeneration(null);
|
||||
setState("loading");
|
||||
reportAdmission({
|
||||
|
|
@ -535,12 +322,7 @@ export function RecordedFmp4Player({
|
|||
byteLength: contract.byteLength,
|
||||
message: null,
|
||||
});
|
||||
void fetchVerifiedRecordedMediaArchive(contract, {
|
||||
signal: abort.signal,
|
||||
onProgress: (next) => {
|
||||
if (!abort.signal.aborted) setProgress(next);
|
||||
},
|
||||
})
|
||||
void fetchRecordedMediaArchive(contract, { signal: abort.signal })
|
||||
.then((loaded) => {
|
||||
if (abort.signal.aborted) return;
|
||||
setArchive(loaded);
|
||||
|
|
@ -566,11 +348,11 @@ export function RecordedFmp4Player({
|
|||
const abort = new AbortController();
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
for (const candidate of archive.epochs) {
|
||||
for (const candidate of archive.manifest.epochs) {
|
||||
const probe = document.createElement("video");
|
||||
probe.muted = true;
|
||||
probe.playsInline = true;
|
||||
const cleanup = await mountVerifiedRecordedEpoch(probe, candidate, abort.signal);
|
||||
const cleanup = await mountRecordedEpochStream(probe, candidate, abort.signal);
|
||||
cleanup();
|
||||
if (disposed || abort.signal.aborted) return;
|
||||
}
|
||||
|
|
@ -602,8 +384,8 @@ export function RecordedFmp4Player({
|
|||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !verifiedEpoch) return;
|
||||
const epochDescriptor = verifiedEpoch.descriptor;
|
||||
if (!video || !epoch) return;
|
||||
const epochDescriptor = epoch;
|
||||
const generation = contract
|
||||
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
|
||||
: null;
|
||||
|
|
@ -615,7 +397,7 @@ export function RecordedFmp4Player({
|
|||
|
||||
const loadEpoch = async () => {
|
||||
try {
|
||||
cleanup = await mountVerifiedRecordedEpoch(video, verifiedEpoch, abort.signal);
|
||||
cleanup = await mountRecordedEpochStream(video, epochDescriptor, abort.signal);
|
||||
if (disposed || abort.signal.aborted) {
|
||||
cleanup();
|
||||
cleanup = null;
|
||||
|
|
@ -648,7 +430,7 @@ export function RecordedFmp4Player({
|
|||
abort.abort();
|
||||
cleanup?.();
|
||||
};
|
||||
}, [archive?.byteLength, contract, verifiedEpoch]);
|
||||
}, [archive?.byteLength, contract, epoch]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
|
@ -679,10 +461,6 @@ export function RecordedFmp4Player({
|
|||
}
|
||||
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
|
||||
|
||||
const progressPercent = progress && progress.totalBytes > 0
|
||||
? Math.min(100, Math.floor((progress.loadedBytes / progress.totalBytes) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="recorded-media-player"
|
||||
|
|
@ -707,8 +485,8 @@ export function RecordedFmp4Player({
|
|||
: visualState === "error"
|
||||
? "Записанное видео недоступно"
|
||||
: archive
|
||||
? "Проверяем полную готовность записанного видео…"
|
||||
: `Загружаем и проверяем записанное видео · ${progressPercent}%`}
|
||||
? "Проверяем seek и codec записанного видео…"
|
||||
: "Читаем manifest записанного видео…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ import type { SceneSettings } from "../sceneSettings";
|
|||
import type { RecordedAdmissionPhase } from "../core/observation/recordedSessionAdmission";
|
||||
|
||||
export type RerunViewportStatus = "idle" | "loading" | "ready" | "error";
|
||||
export type RecordedRerunView = "spatial" | "perception" | "metrics";
|
||||
export type RecordedRerunView = "spatial" | "perception" | "perception3d" | "metrics";
|
||||
|
||||
export interface RecordedPerceptionLayers {
|
||||
enabled: boolean;
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
}
|
||||
|
||||
export interface RerunSelection {
|
||||
entityPath: string;
|
||||
|
|
@ -57,6 +64,8 @@ export interface RerunViewportProps {
|
|||
| "customColor"
|
||||
>;
|
||||
recordedView?: RecordedRerunView;
|
||||
recordedViewResetGeneration?: 0 | 1;
|
||||
recordedPerceptionLayers?: RecordedPerceptionLayers;
|
||||
onPerceptionAvailabilityChange?: (available: boolean) => void;
|
||||
}
|
||||
|
||||
|
|
@ -438,11 +447,20 @@ export async function fetchRecordedBlueprintRrd(
|
|||
origin,
|
||||
signal,
|
||||
activeView = "spatial",
|
||||
viewResetGeneration = 0,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
segmentation: false,
|
||||
cuboids3d: false,
|
||||
},
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin: string;
|
||||
signal?: AbortSignal;
|
||||
activeView?: RecordedRerunView;
|
||||
viewResetGeneration?: 0 | 1;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
|
|
@ -461,7 +479,14 @@ export async function fetchRecordedBlueprintRrd(
|
|||
settings.pointSize > 32 ||
|
||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
!["spatial", "perception", "metrics"].includes(activeView) ||
|
||||
!["spatial", "perception", "perception3d", "metrics"].includes(activeView) ||
|
||||
![0, 1].includes(viewResetGeneration) ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
perceptionLayers.detections2d,
|
||||
perceptionLayers.segmentation,
|
||||
perceptionLayers.cuboids3d,
|
||||
].some((value) => typeof value !== "boolean") ||
|
||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
||||
) {
|
||||
|
|
@ -485,6 +510,11 @@ export async function fetchRecordedBlueprintRrd(
|
|||
palette: settings.palette,
|
||||
custom_color: settings.customColor,
|
||||
active_view: activeView,
|
||||
view_reset_generation: viewResetGeneration,
|
||||
unified_perception: perceptionLayers.enabled,
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_segmentation: perceptionLayers.segmentation,
|
||||
show_cuboids_3d: perceptionLayers.cuboids3d,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
|
@ -588,6 +618,13 @@ export function RerunViewport({
|
|||
onPlaybackControllerChange,
|
||||
sceneSettings,
|
||||
recordedView = "spatial",
|
||||
recordedViewResetGeneration = 0,
|
||||
recordedPerceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
segmentation: false,
|
||||
cuboids3d: false,
|
||||
},
|
||||
onPerceptionAvailabilityChange,
|
||||
}: RerunViewportProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -596,6 +633,7 @@ export function RerunViewport({
|
|||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
||||
const presentationGateRef = useRef(presentationGate);
|
||||
presentationGateRef.current = presentationGate;
|
||||
|
|
@ -1123,11 +1161,11 @@ export function RerunViewport({
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onPerceptionAvailabilityChange?.(false);
|
||||
}, [onPerceptionAvailabilityChange, recordedPerceptionUrl]);
|
||||
loadedPerceptionChannelRef.current = null;
|
||||
}, [recordedPerceptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedPerceptionUrl) return;
|
||||
if (!recordedPerceptionUrl || !recordedPerceptionLayers.enabled) return;
|
||||
const active = perceptionChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
if (
|
||||
|
|
@ -1136,6 +1174,10 @@ export function RerunViewport({
|
|||
active.endpointUrl !== recordedPerceptionUrl ||
|
||||
!active.channel.ready
|
||||
) return;
|
||||
if (loadedPerceptionChannelRef.current === active) {
|
||||
onPerceptionAvailabilityChange?.(true);
|
||||
return;
|
||||
}
|
||||
const abort = new AbortController();
|
||||
void fetchRecordedPerceptionRrd(recordedPerceptionUrl, identity, {
|
||||
origin: window.location.origin,
|
||||
|
|
@ -1154,6 +1196,7 @@ export function RerunViewport({
|
|||
return;
|
||||
}
|
||||
active.channel.send_rrd(payload);
|
||||
loadedPerceptionChannelRef.current = active;
|
||||
onPerceptionAvailabilityChange?.(true);
|
||||
}).catch(() => {
|
||||
onPerceptionAvailabilityChange?.(false);
|
||||
|
|
@ -1163,6 +1206,7 @@ export function RerunViewport({
|
|||
}, [
|
||||
onPerceptionAvailabilityChange,
|
||||
perceptionChannelRevision,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionUrl,
|
||||
]);
|
||||
|
||||
|
|
@ -1181,6 +1225,8 @@ export function RerunViewport({
|
|||
origin: window.location.origin,
|
||||
signal: abort.signal,
|
||||
activeView: recordedView,
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
perceptionLayers: recordedPerceptionLayers,
|
||||
}).then((payload) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
|
|
@ -1200,6 +1246,11 @@ export function RerunViewport({
|
|||
blueprintChannelRevision,
|
||||
recordedBlueprintUrl,
|
||||
recordedView,
|
||||
recordedViewResetGeneration,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionLayers.detections2d,
|
||||
recordedPerceptionLayers.segmentation,
|
||||
recordedPerceptionLayers.cuboids3d,
|
||||
sceneSettings?.accumulationSeconds,
|
||||
sceneSettings?.customColor,
|
||||
sceneSettings?.palette,
|
||||
|
|
|
|||
|
|
@ -38,47 +38,57 @@ export function recordedObservationSources(
|
|||
spatialRegistration: "native",
|
||||
},
|
||||
};
|
||||
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => ({
|
||||
id: source.id,
|
||||
sourceId: source.id,
|
||||
semanticChannelId: "camera.video.recorded",
|
||||
label: source.label,
|
||||
description: "Сохранённый видеоканал на общей временной шкале сессии",
|
||||
modality: "video",
|
||||
role: "auxiliary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "Сохранённая сессия",
|
||||
previewUrl: null,
|
||||
delivery: {
|
||||
id: `${launch.sessionId}:${source.id}`,
|
||||
kind: "recorded-fmp4-manifest",
|
||||
url: source.manifestUrl,
|
||||
mediaType: source.mediaType,
|
||||
manifestGenerationSha256: source.manifestGenerationSha256,
|
||||
byteLength: source.byteLength,
|
||||
timelineStartSeconds: source.timelineStartSeconds,
|
||||
timelineEndSeconds: source.timelineEndSeconds,
|
||||
},
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: "recorded-media",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: true,
|
||||
fullscreen: true,
|
||||
resizable: true,
|
||||
defaultVisible: index < 2,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: "session_time",
|
||||
spatialRegistration: "unresolved",
|
||||
},
|
||||
}));
|
||||
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => {
|
||||
const perception = source.id.startsWith("recorded.perception.");
|
||||
return {
|
||||
id: source.id,
|
||||
sourceId: source.id,
|
||||
semanticChannelId: perception
|
||||
? "camera.perception.panoptic.recorded"
|
||||
: "camera.video.recorded",
|
||||
label: source.label,
|
||||
description: perception
|
||||
? "Покадровая instance + semantic сегментация на общей временной шкале"
|
||||
: "Сохранённый видеоканал на общей временной шкале сессии",
|
||||
modality: "video",
|
||||
role: "auxiliary",
|
||||
availability: "available",
|
||||
transport: "recording",
|
||||
endpointLabel: "Сохранённая сессия",
|
||||
previewUrl: null,
|
||||
delivery: {
|
||||
id: `${launch.sessionId}:${source.id}`,
|
||||
kind: "recorded-fmp4-manifest",
|
||||
url: source.manifestUrl,
|
||||
mediaType: source.mediaType,
|
||||
manifestGenerationSha256: source.manifestGenerationSha256,
|
||||
byteLength: source.byteLength,
|
||||
timelineStartSeconds: source.timelineStartSeconds,
|
||||
timelineEndSeconds: source.timelineEndSeconds,
|
||||
},
|
||||
activation: null,
|
||||
provider: {
|
||||
pluginId: "missioncore.session-archive",
|
||||
pluginVersion: "1",
|
||||
modelId: perception ? "recorded-panoptic-perception" : "recorded-media",
|
||||
compatibilityProfileId: null,
|
||||
},
|
||||
binding: {},
|
||||
capabilities: {
|
||||
overlay: true,
|
||||
fullscreen: true,
|
||||
resizable: true,
|
||||
// AI presentation belongs to the unified Rerun composition. Keep the
|
||||
// pre-rendered perception video as an explicit fallback instead of
|
||||
// opening it as a second, independently controlled stream.
|
||||
defaultVisible: !perception && index < 2,
|
||||
timelineMode: "recorded",
|
||||
seekable: true,
|
||||
sessionRecording: true,
|
||||
clockId: "session_time",
|
||||
spatialRegistration: perception ? "calibrated" : "unresolved",
|
||||
},
|
||||
};
|
||||
});
|
||||
return [spatial, ...media];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
||||
// vehicles while the independent byte/concurrency limits keep admission
|
||||
// bounded. One real accepted archive is ~163 MiB for a single camera, so the
|
||||
// old 128 MiB laboratory ceiling rejected a valid sealed generation before the
|
||||
// first manifest request. OPFS-backed sealed generations remain the scaling
|
||||
// path beyond this in-memory policy.
|
||||
// Camera count and preparation concurrency remain device-agnostic scheduling
|
||||
// policy. Recorded duration and aggregate bytes are deliberately not admission
|
||||
// criteria: sealed media is presented through a generation-bound HTTP stream,
|
||||
// so a one-, three- or ten-hour recording never has to fit in browser memory.
|
||||
export const MAX_RECORDED_CAMERA_SOURCES = 16;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 256 * 1024 * 1024;
|
||||
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
|
||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||
|
||||
export type RecordedAdmissionPhase = "loading" | "ready" | "error";
|
||||
|
|
@ -32,18 +28,12 @@ export function recordedCameraDescriptorPreflight(
|
|||
): RecordedAdmissionPhase {
|
||||
if (sources.length > MAX_RECORDED_CAMERA_SOURCES) return "error";
|
||||
if (new Set(sources.map(({ id }) => id)).size !== sources.length) return "error";
|
||||
let totalBytes = 0;
|
||||
for (const source of sources) {
|
||||
if (
|
||||
!source.id ||
|
||||
!Number.isSafeInteger(source.byteLength) ||
|
||||
source.byteLength < 1 ||
|
||||
source.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
|
||||
source.byteLength < 1
|
||||
) return "error";
|
||||
totalBytes += source.byteLength;
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) {
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
|
@ -67,7 +57,6 @@ export function recordedSessionAdmissionPhase(
|
|||
if (sourceIds.length > MAX_RECORDED_CAMERA_SOURCES || spatialPhase === "error") {
|
||||
return "error";
|
||||
}
|
||||
let totalBytes = 0;
|
||||
for (const sourceId of sourceIds) {
|
||||
const camera = cameras[sourceId];
|
||||
if (!camera || camera.phase === "error") return "error";
|
||||
|
|
@ -75,13 +64,10 @@ export function recordedSessionAdmissionPhase(
|
|||
if (camera.byteLength !== null) {
|
||||
if (
|
||||
!Number.isSafeInteger(camera.byteLength) ||
|
||||
camera.byteLength < 1 ||
|
||||
camera.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
|
||||
camera.byteLength < 1
|
||||
) return "error";
|
||||
totalBytes += camera.byteLength;
|
||||
}
|
||||
}
|
||||
if (totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) return "error";
|
||||
if (spatialPhase !== "ready") return "loading";
|
||||
return sourceIds.every((sourceId) => cameras[sourceId]?.phase === "ready")
|
||||
? "ready"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
MAX_RECORDED_SESSION_CAMERA_BYTES,
|
||||
} from "./recordedSessionAdmission";
|
||||
|
||||
export type ObservationSessionStatus =
|
||||
|
|
@ -99,19 +97,8 @@ export interface ObservationRecordedMediaEpoch {
|
|||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
mediaType: string;
|
||||
initUrl: string;
|
||||
initByteLength: number;
|
||||
initSha256: string;
|
||||
segmentCount: number;
|
||||
segmentUrlPrefix: string;
|
||||
segments: readonly ObservationRecordedMediaSegment[];
|
||||
}
|
||||
|
||||
export interface ObservationRecordedMediaSegment {
|
||||
sequence: number;
|
||||
url: string;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
streamUrl: string;
|
||||
}
|
||||
|
||||
export interface ObservationRecordedMediaManifest {
|
||||
|
|
@ -215,18 +202,8 @@ const RECORDED_MEDIA_EPOCH_KEYS = new Set([
|
|||
"timeline_start_seconds",
|
||||
"timeline_end_seconds",
|
||||
"media_type",
|
||||
"init_url",
|
||||
"init_byte_length",
|
||||
"init_sha256",
|
||||
"segment_count",
|
||||
"segment_url_prefix",
|
||||
"segments",
|
||||
]);
|
||||
const RECORDED_MEDIA_SEGMENT_KEYS = new Set([
|
||||
"sequence",
|
||||
"url",
|
||||
"byte_length",
|
||||
"sha256",
|
||||
"stream_url",
|
||||
]);
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const SAFE_MODALITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
||||
|
|
@ -234,10 +211,9 @@ const ISO_WITH_TIMEZONE = /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/;
|
|||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const SAFE_RECORDING_URL = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9._%:-]+\/recording\.rrd$/;
|
||||
const SAFE_PREPARATION_URL = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9._%:-]+\/recording-preparation$/;
|
||||
const SAFE_MEDIA_MANIFEST_URL = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9._%:-]+\/media\/[A-Za-z0-9._%-]+\/manifest$/;
|
||||
const SAFE_MEDIA_MANIFEST_URL = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9._%:-]+\/(?:media\/[A-Za-z0-9._%-]+|perception-media\/result-[a-f0-9]{64})\/manifest$/;
|
||||
const SAFE_MEDIA_STREAM_URL = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9._%:-]+\/(?:media\/[A-Za-z0-9._%-]+\/epochs\/[1-9][0-9]*|perception-media\/result-[a-f0-9]{64})\/recording\.mp4\?generation=[a-f0-9]{64}$/;
|
||||
const SAFE_MP4_MEDIA_TYPE = /^video\/mp4(?:; codecs="[A-Za-z0-9.,_-]+")?$/;
|
||||
const MAX_RECORDED_INIT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_RECORDED_SEGMENT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export class ObservationSessionContractError extends Error {
|
||||
constructor(message: string) {
|
||||
|
|
@ -570,15 +546,6 @@ export function decodeObservationSessionReplay(
|
|||
"Descriptor записи содержит повторяющиеся медиаканалы.",
|
||||
);
|
||||
}
|
||||
const mediaByteLength = mediaSources.reduce((total, source) => total + source.byteLength, 0);
|
||||
if (
|
||||
!Number.isSafeInteger(mediaByteLength) ||
|
||||
mediaByteLength > MAX_RECORDED_SESSION_CAMERA_BYTES
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Совокупный объём записанных медиаканалов превышает безопасный лимит браузера.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "rerun-recording",
|
||||
sessionId,
|
||||
|
|
@ -709,7 +676,10 @@ function decodeRecordedMediaSource(
|
|||
}
|
||||
assertExactKeys(value, RECORDED_MEDIA_SOURCE_KEYS, `Медиаканал ${index}`);
|
||||
const id = requireString(value.id, `media_sources[${index}].id`, 128);
|
||||
if (!SAFE_ID.test(id) || !id.startsWith("recorded.camera.")) {
|
||||
if (
|
||||
!SAFE_ID.test(id) ||
|
||||
(!id.startsWith("recorded.camera.") && !id.startsWith("recorded.perception."))
|
||||
) {
|
||||
throw new ObservationSessionContractError("Медиаканал содержит небезопасный opaque id.");
|
||||
}
|
||||
if (value.modality !== "video" || value.media_type !== "video/mp4") {
|
||||
|
|
@ -728,10 +698,10 @@ function decodeRecordedMediaSource(
|
|||
"Медиаканал не содержит immutable generation SHA-256.",
|
||||
);
|
||||
}
|
||||
const sessionPrefix = `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/media/`;
|
||||
const sessionBase = `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/`;
|
||||
if (
|
||||
!SAFE_MEDIA_MANIFEST_URL.test(manifestUrl) ||
|
||||
!manifestUrl.startsWith(sessionPrefix) ||
|
||||
!manifestUrl.startsWith(sessionBase) ||
|
||||
manifestUrl.includes("..")
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
|
|
@ -765,7 +735,7 @@ function decodeRecordedMediaSource(
|
|||
byteLength: requireFiniteNumber(
|
||||
value.byte_length,
|
||||
`media_sources[${index}].byte_length`,
|
||||
{ minimum: 1, maximum: MAX_RECORDED_MEDIA_SOURCE_BYTES, integer: true },
|
||||
{ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, integer: true },
|
||||
),
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds,
|
||||
|
|
@ -784,22 +754,21 @@ export function decodeObservationRecordedMediaManifest(
|
|||
}
|
||||
assertExactKeys(payload, RECORDED_MEDIA_MANIFEST_KEYS, "Manifest записанного видео");
|
||||
if (
|
||||
payload.schema_version !== "missioncore.observation-recorded-media/v2" ||
|
||||
payload.schema_version !== "missioncore.observation-recorded-media/v3" ||
|
||||
payload.source_id !== source.id ||
|
||||
typeof payload.generation_sha256 !== "string" ||
|
||||
!SHA256.test(payload.generation_sha256) ||
|
||||
payload.generation_sha256 !== source.manifestGenerationSha256 ||
|
||||
payload.synchronization !== "host-arrival-best-effort" ||
|
||||
!Array.isArray(payload.epochs) ||
|
||||
payload.epochs.length < 1 ||
|
||||
payload.epochs.length > 1_000
|
||||
payload.epochs.length < 1
|
||||
) {
|
||||
throw new ObservationSessionContractError("Manifest записанного видео несовместим.");
|
||||
}
|
||||
const manifestBase = source.manifestUrl.slice(0, -"/manifest".length);
|
||||
const manifestByteLength = requireFiniteNumber(payload.byte_length, "manifest.byte_length", {
|
||||
minimum: 1,
|
||||
maximum: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
integer: true,
|
||||
});
|
||||
if (manifestByteLength !== source.byteLength) {
|
||||
|
|
@ -833,7 +802,7 @@ export function decodeObservationRecordedMediaManifest(
|
|||
assertExactKeys(entry, RECORDED_MEDIA_EPOCH_KEYS, `Codec epoch ${index}`);
|
||||
const ordinal = requireFiniteNumber(entry.ordinal, `epochs[${index}].ordinal`, {
|
||||
minimum: 1,
|
||||
maximum: 1_000,
|
||||
maximum: Number.MAX_SAFE_INTEGER,
|
||||
integer: true,
|
||||
});
|
||||
if (ordinal !== index + 1) {
|
||||
|
|
@ -860,87 +829,28 @@ export function decodeObservationRecordedMediaManifest(
|
|||
if (!SAFE_MP4_MEDIA_TYPE.test(mediaType)) {
|
||||
throw new ObservationSessionContractError("Codec epoch содержит небезопасный media type.");
|
||||
}
|
||||
const initUrl = requireString(entry.init_url, `epochs[${index}].init_url`, 512);
|
||||
const segmentUrlPrefix = requireString(
|
||||
entry.segment_url_prefix,
|
||||
`epochs[${index}].segment_url_prefix`,
|
||||
512,
|
||||
const byteLength = requireFiniteNumber(
|
||||
entry.byte_length,
|
||||
`epochs[${index}].byte_length`,
|
||||
{ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, integer: true },
|
||||
);
|
||||
const expectedEpochBase = `${manifestBase}/epochs/${ordinal}`;
|
||||
const streamUrl = requireString(entry.stream_url, `epochs[${index}].stream_url`, 768);
|
||||
const expectedEpochBase = source.id.startsWith("recorded.perception.")
|
||||
? manifestBase
|
||||
: `${manifestBase}/epochs/${ordinal}`;
|
||||
const expectedStreamUrl =
|
||||
`${expectedEpochBase}/recording.mp4?generation=${payload.generation_sha256}`;
|
||||
if (
|
||||
initUrl !== `${expectedEpochBase}/init.mp4` ||
|
||||
segmentUrlPrefix !== `${expectedEpochBase}/segments/` ||
|
||||
initUrl.includes("..") ||
|
||||
segmentUrlPrefix.includes("..")
|
||||
!SAFE_MEDIA_STREAM_URL.test(streamUrl) ||
|
||||
streamUrl !== expectedStreamUrl ||
|
||||
streamUrl.includes("..")
|
||||
) {
|
||||
throw new ObservationSessionContractError("Codec epoch содержит небезопасный API URL.");
|
||||
}
|
||||
const initByteLength = requireFiniteNumber(
|
||||
entry.init_byte_length,
|
||||
`epochs[${index}].init_byte_length`,
|
||||
{ minimum: 1, maximum: MAX_RECORDED_INIT_BYTES, integer: true },
|
||||
);
|
||||
if (typeof entry.init_sha256 !== "string" || !SHA256.test(entry.init_sha256)) {
|
||||
throw new ObservationSessionContractError("Codec epoch не содержит SHA-256 init-сегмента.");
|
||||
}
|
||||
const segmentCount = requireFiniteNumber(
|
||||
entry.segment_count,
|
||||
`epochs[${index}].segment_count`,
|
||||
{ minimum: 1, maximum: 500_000, integer: true },
|
||||
);
|
||||
if (!Array.isArray(entry.segments) || entry.segments.length !== segmentCount) {
|
||||
declaredBytes += byteLength;
|
||||
if (!Number.isSafeInteger(declaredBytes)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Codec epoch содержит неполный список канонических сегментов.",
|
||||
);
|
||||
}
|
||||
const segments = entry.segments.map((segment, segmentIndex): ObservationRecordedMediaSegment => {
|
||||
if (!isRecord(segment)) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Сегмент epochs[${index}].segments[${segmentIndex}] должен быть объектом.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(
|
||||
segment,
|
||||
RECORDED_MEDIA_SEGMENT_KEYS,
|
||||
`Сегмент epochs[${index}].segments[${segmentIndex}]`,
|
||||
);
|
||||
const sequence = requireFiniteNumber(
|
||||
segment.sequence,
|
||||
`epochs[${index}].segments[${segmentIndex}].sequence`,
|
||||
{ minimum: 1, maximum: 500_000, integer: true },
|
||||
);
|
||||
if (sequence !== segmentIndex + 1) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Codec epoch содержит непоследовательный сегмент.",
|
||||
);
|
||||
}
|
||||
const url = requireString(
|
||||
segment.url,
|
||||
`epochs[${index}].segments[${segmentIndex}].url`,
|
||||
512,
|
||||
);
|
||||
if (url !== `${segmentUrlPrefix}${sequence}.m4s` || url.includes("..")) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Codec epoch содержит небезопасный URL сегмента.",
|
||||
);
|
||||
}
|
||||
const byteLength = requireFiniteNumber(
|
||||
segment.byte_length,
|
||||
`epochs[${index}].segments[${segmentIndex}].byte_length`,
|
||||
{ minimum: 1, maximum: MAX_RECORDED_SEGMENT_BYTES, integer: true },
|
||||
);
|
||||
if (typeof segment.sha256 !== "string" || !SHA256.test(segment.sha256)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Codec epoch содержит сегмент без SHA-256.",
|
||||
);
|
||||
}
|
||||
declaredBytes += byteLength;
|
||||
return { sequence, url, byteLength, sha256: segment.sha256 };
|
||||
});
|
||||
declaredBytes += initByteLength;
|
||||
if (declaredBytes > MAX_RECORDED_MEDIA_SOURCE_BYTES) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Записанный медиаканал превышает безопасный лимит браузера.",
|
||||
"Суммарный размер codec epoch выходит за точный числовой диапазон клиента.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
|
|
@ -948,17 +858,13 @@ export function decodeObservationRecordedMediaManifest(
|
|||
timelineStartSeconds,
|
||||
timelineEndSeconds,
|
||||
mediaType,
|
||||
initUrl,
|
||||
initByteLength,
|
||||
initSha256: entry.init_sha256,
|
||||
segmentCount,
|
||||
segmentUrlPrefix,
|
||||
segments,
|
||||
byteLength,
|
||||
streamUrl,
|
||||
};
|
||||
});
|
||||
if (declaredBytes !== manifestByteLength) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Сумма init- и media-сегментов не совпадает с размером immutable manifest.",
|
||||
"Сумма codec epoch не совпадает с размером immutable manifest.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -285,11 +285,16 @@ export async function resolveObservationSessionReplay(
|
|||
sessionId: string,
|
||||
options: ObservationPreparationPollingOptions,
|
||||
): Promise<ObservationSessionReplayLaunch> {
|
||||
const response = await withRequestTimeout(
|
||||
options.signal,
|
||||
Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS),
|
||||
(signal) => replayObservationSession(sessionId, { signal, fetcher: options.fetcher }),
|
||||
);
|
||||
// The initial replay request may restore and integrity-check large, already
|
||||
// published artifacts before it can return either a launch descriptor or a
|
||||
// background-preparation handle. Its duration therefore scales with the
|
||||
// recording package and must not be confused with a stalled status poll.
|
||||
// Keep it cancellable by the owning UI attempt, but do not impose the short
|
||||
// per-poll timeout used once the server has returned a preparation handle.
|
||||
const response = await replayObservationSession(sessionId, {
|
||||
signal: options.signal,
|
||||
fetcher: options.fetcher,
|
||||
});
|
||||
if (response.kind === "ready") return response.launch;
|
||||
if (!pendingPreparation(response.preparation)) {
|
||||
options.onUpdate?.(response.preparation);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ export interface ViewerSettings {
|
|||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
show_detections_2d: boolean;
|
||||
show_segmentation: boolean;
|
||||
show_cuboids_3d: boolean;
|
||||
}
|
||||
|
||||
export interface StreamMetrics {
|
||||
|
|
@ -29,6 +32,10 @@ export interface StreamMetrics {
|
|||
frameRateHz?: number | null;
|
||||
pointCount?: number | null;
|
||||
droppedPreviewFrames?: number | null;
|
||||
aiLatencyMs?: number | null;
|
||||
aiFrameRateHz?: number | null;
|
||||
aiDroppedFrames?: number | null;
|
||||
aiStaleMs?: number | null;
|
||||
elapsedSeconds?: number | null;
|
||||
routeDistanceMeters?: number | null;
|
||||
speedMetersPerSecond?: number | null;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,25 @@
|
|||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.scene-navigation-hint {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
right: 0.85rem;
|
||||
bottom: 4.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(9 10 13 / 0.7);
|
||||
color: var(--nodedc-text-muted);
|
||||
padding: 0.42rem 0.65rem;
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.scene-source-picker__trigger {
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -350,6 +369,11 @@ i[data-availability="error"] {
|
|||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.34);
|
||||
}
|
||||
|
||||
.floating-observation-window--hidden {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.observation-timeline {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,20 @@
|
|||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.spatial-toolbar__view-switch .nodedc-button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.spatial-viewport-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ import {
|
|||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
type RecordedRerunView,
|
||||
} from "../components/RerunViewport";
|
||||
import {
|
||||
capabilityStatusLabel,
|
||||
|
|
@ -124,6 +123,16 @@ export interface WorkspaceRendererProps {
|
|||
accumulationSeconds: number;
|
||||
onAccumulationChange: (value: number) => void;
|
||||
onAccumulationCommit: () => void;
|
||||
livePerceptionLayers: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
};
|
||||
onLivePerceptionLayersChange: (next: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
}) => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
|
|
@ -286,6 +295,8 @@ function SpatialWorkspace({
|
|||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
livePerceptionLayers,
|
||||
onLivePerceptionLayersChange,
|
||||
observationLayout,
|
||||
navigation,
|
||||
spatialControls,
|
||||
|
|
@ -295,8 +306,13 @@ function SpatialWorkspace({
|
|||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const [recordedRerunView, setRecordedRerunView] = useState<RecordedRerunView>("spatial");
|
||||
const [perceptionAvailable, setPerceptionAvailable] = useState(false);
|
||||
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [perceptionAvailability, setPerceptionAvailability] = useState<
|
||||
"unknown" | "available" | "unavailable"
|
||||
>("unknown");
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
|
|
@ -309,6 +325,8 @@ function SpatialWorkspace({
|
|||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
|
||||
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
|
||||
const observationSources = state?.observationSources ?? [];
|
||||
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
|
||||
const pointCloudVisible = pointCloudSource
|
||||
|
|
@ -317,13 +335,29 @@ function SpatialWorkspace({
|
|||
const mediaSources = observationSources.filter(
|
||||
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
||||
);
|
||||
const recordedPerceptionSupported = recordedSource && perceptionAvailability !== "unavailable";
|
||||
const recordedPerceptionEnabled = showDetections2d || showSegmentation || showCuboids3d;
|
||||
const unifiedPerception = recordedPerceptionSupported && recordedPerceptionEnabled;
|
||||
const livePerceptionAvailable = !recordedSource && streamActive;
|
||||
const detections2dActive = recordedSource
|
||||
? showDetections2d
|
||||
: livePerceptionLayers.detections2d;
|
||||
const segmentationActive = recordedSource
|
||||
? showSegmentation
|
||||
: livePerceptionLayers.segmentation;
|
||||
const cuboids3dActive = recordedSource
|
||||
? showCuboids3d
|
||||
: livePerceptionLayers.cuboids3d;
|
||||
const visibleMediaSources = mediaSources.filter((source) =>
|
||||
observationLayout.visibleSourceIds.has(source.id),
|
||||
observationLayout.visibleSourceIds.has(source.id) &&
|
||||
!source.id.startsWith("recorded.perception."),
|
||||
);
|
||||
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
|
||||
const pointCloudFocused = Boolean(
|
||||
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
|
||||
);
|
||||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const floatingSourceMaximized = !unifiedPerception &&
|
||||
Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const intentionalSourceEnd = !recordedSource && [
|
||||
|
|
@ -373,13 +407,20 @@ function SpatialWorkspace({
|
|||
[],
|
||||
);
|
||||
const onPerceptionAvailabilityChange = useCallback((available: boolean) => {
|
||||
setPerceptionAvailable(available);
|
||||
if (!available) setRecordedRerunView("spatial");
|
||||
setPerceptionAvailability(available ? "available" : "unavailable");
|
||||
if (!available) {
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPerceptionAvailable(false);
|
||||
setRecordedRerunView("spatial");
|
||||
setPerceptionAvailability("unknown");
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setRecordedViewResetGeneration(0);
|
||||
}, [sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -389,8 +430,10 @@ function SpatialWorkspace({
|
|||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
setPerceptionAvailable(false);
|
||||
setRecordedRerunView("spatial");
|
||||
setPerceptionAvailability("unknown");
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -428,20 +471,74 @@ function SpatialWorkspace({
|
|||
className="spatial-workspace"
|
||||
data-focused={pointCloudFocused || floatingSourceMaximized ? "true" : undefined}
|
||||
>
|
||||
<div className="spatial-toolbar">
|
||||
<div className="spatial-toolbar" data-viewer-controls="v1">
|
||||
<div className="spatial-toolbar__mode">
|
||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||
</div>
|
||||
<div className="spatial-toolbar__actions">
|
||||
{recordedSource && perceptionAvailable ? (
|
||||
{recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
aria-pressed="true"
|
||||
disabled
|
||||
>
|
||||
Оригинал
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={detections2dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={detections2dActive}
|
||||
onClick={() => recordedSource
|
||||
? setShowDetections2d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
detections2d: !livePerceptionLayers.detections2d,
|
||||
})}
|
||||
>
|
||||
Объекты 2D
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={segmentationActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="image" />}
|
||||
aria-pressed={segmentationActive}
|
||||
onClick={() => recordedSource
|
||||
? setShowSegmentation((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
segmentation: !livePerceptionLayers.segmentation,
|
||||
})}
|
||||
>
|
||||
Сегментация
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="apps" />}
|
||||
aria-pressed={cuboids3dActive}
|
||||
onClick={() => recordedSource
|
||||
? setShowCuboids3d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
cuboids3d: !livePerceptionLayers.cuboids3d,
|
||||
})}
|
||||
>
|
||||
Кубы 3D
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={recordedRerunView === "perception" ? "primary" : "secondary"}
|
||||
icon={<Icon name={recordedRerunView === "perception" ? "globe" : "image"} />}
|
||||
onClick={() => setRecordedRerunView((current) =>
|
||||
current === "perception" ? "spatial" : "perception")}
|
||||
variant="secondary"
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
|
||||
>
|
||||
{recordedRerunView === "perception" ? "Облако точек" : "Распознавание"}
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
|
|
@ -476,7 +573,13 @@ function SpatialWorkspace({
|
|||
? state?.observationTimeline?.range?.endSeconds
|
||||
: undefined}
|
||||
sceneSettings={sceneSettings}
|
||||
recordedView={recordedRerunView}
|
||||
recordedViewResetGeneration={recordedViewResetGeneration}
|
||||
recordedPerceptionLayers={{
|
||||
enabled: unifiedPerception,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
}}
|
||||
onPerceptionAvailabilityChange={onPerceptionAvailabilityChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
|
|
@ -511,7 +614,9 @@ function SpatialWorkspace({
|
|||
) : !floatingSourceMaximized ? (
|
||||
<div className="scene-source-controls">
|
||||
<ObservationSourcePicker
|
||||
sources={observationSources}
|
||||
sources={unifiedPerception
|
||||
? observationSources.filter((source) => source.modality === "point-cloud")
|
||||
: observationSources}
|
||||
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||
pendingSourceIds={observationLayout.pendingSourceIds}
|
||||
onToggle={observationLayout.toggleSource}
|
||||
|
|
@ -555,6 +660,16 @@ function SpatialWorkspace({
|
|||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
{streamActive ? (
|
||||
<div>
|
||||
<span>AI</span>
|
||||
<strong>
|
||||
{aiLatency === null ? "—" : formatNumber(aiLatency)}
|
||||
<small>{aiLatency === null ? "" : " мс"}</small>
|
||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
|
|
@ -575,10 +690,17 @@ function SpatialWorkspace({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{presentedViewerStatus === "ready" && !floatingSourceMaximized ? (
|
||||
<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене">
|
||||
<span>Колесо · зум к курсору</span>
|
||||
<span>WASD · свободный проход</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
|
|
@ -599,7 +721,7 @@ function SpatialWorkspace({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused ? visibleMediaSources.map((source, index) => (
|
||||
{visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
|
|
@ -609,6 +731,7 @@ function SpatialWorkspace({
|
|||
rect={observationLayout.windowRects[source.id]}
|
||||
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||
active={observationLayout.activeFloatingSourceId === source.id}
|
||||
hidden={pointCloudFocused || unifiedPerception}
|
||||
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
|
||||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
|
|
@ -627,12 +750,12 @@ function SpatialWorkspace({
|
|||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
/>
|
||||
)) : null}
|
||||
))}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
(pointCloudFocused || !observationLayout.visibleSourceIds.has(source.id)) &&
|
||||
!observationLayout.visibleSourceIds.has(source.id) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
|
|
@ -657,7 +780,9 @@ function SpatialWorkspace({
|
|||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state={perceptionAvailable ? "ready" : "contract"} />Объекты / рамки</span>
|
||||
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
|
||||
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -484,6 +484,31 @@ test("202 preparation polls through explicit phases and reveals launch only when
|
|||
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
|
||||
});
|
||||
|
||||
test("initial replay restore is not mistaken for a stalled preparation poll", async () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
let requestSignal;
|
||||
const launch = await resolveObservationSessionReplay(sessionId, {
|
||||
signal: new AbortController().signal,
|
||||
requestTimeoutMs: 100,
|
||||
fetcher: async (_input, init) => {
|
||||
requestSignal = init.signal;
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 150);
|
||||
init.signal.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("cancelled", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal(requestSignal.aborted, false);
|
||||
assert.equal(launch.sessionId, sessionId);
|
||||
});
|
||||
|
||||
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
|
||||
const decoded = decodeObservationSessionPreparation(
|
||||
preparation({ state: "finalizing", progress: 0.95 }),
|
||||
|
|
@ -704,6 +729,29 @@ test("switching saved sessions aborts only local polling and never cancels share
|
|||
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
|
||||
});
|
||||
|
||||
test("recording preparation never presents phase heartbeats as fake percentages", async () => {
|
||||
const selectorSource = await readFile(
|
||||
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(selectorSource, /exporting:\s*["']Готовим операторскую сцену["']/);
|
||||
assert.doesNotMatch(selectorSource, /Math\.round\(progress\s*\*\s*100\)/);
|
||||
});
|
||||
|
||||
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
|
||||
const workspaceSource = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(workspaceSource, /hidden=\{pointCloudFocused \|\| unifiedPerception\}/);
|
||||
assert.match(
|
||||
workspaceSource,
|
||||
/presentedMediaSourceCount = unifiedPerception \? 0 : visibleMediaSources\.length/,
|
||||
);
|
||||
});
|
||||
|
||||
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
|
||||
assert.throws(
|
||||
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
|
||||
|
|
@ -776,7 +824,7 @@ test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest
|
|||
);
|
||||
|
||||
const manifest = decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_266,
|
||||
|
|
@ -788,21 +836,15 @@ test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest
|
|||
timeline_start_seconds: 0.25,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: source.manifest_url.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 12,
|
||||
segment_url_prefix: source.manifest_url.replace("/manifest", "/epochs/1/segments/"),
|
||||
segments: Array.from({ length: 12 }, (_, index) => ({
|
||||
sequence: index + 1,
|
||||
url: source.manifest_url.replace("/manifest", `/epochs/1/segments/${index + 1}.m4s`),
|
||||
byte_length: 256 + index,
|
||||
sha256: "b".repeat(64),
|
||||
})),
|
||||
byte_length: 3_266,
|
||||
stream_url: source.manifest_url.replace(
|
||||
"/manifest",
|
||||
`/epochs/1/recording.mp4?generation=${"c".repeat(64)}`,
|
||||
),
|
||||
}],
|
||||
}, decoded.mediaSources[0]);
|
||||
assert.equal(manifest.epochs[0].segmentCount, 12);
|
||||
assert.equal(manifest.epochs[0].segments.length, 12);
|
||||
assert.equal(manifest.epochs[0].byteLength, 3_266);
|
||||
assert.match(manifest.epochs[0].streamUrl, /recording\.mp4\?generation=/);
|
||||
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
|
|
@ -821,7 +863,7 @@ test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest
|
|||
);
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: source.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 3_267,
|
||||
|
|
@ -834,6 +876,53 @@ test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest
|
|||
);
|
||||
});
|
||||
|
||||
test("replay decodes a synchronized full-epoch perception video", () => {
|
||||
const sessionId = "20260720T065719Z_viewer_live";
|
||||
const resultId = `result-${"d".repeat(64)}`;
|
||||
const generation = "e".repeat(64);
|
||||
const source = {
|
||||
id: "recorded.perception.right",
|
||||
label: "Сегментация · камера right",
|
||||
modality: "video",
|
||||
manifest_url: `/api/v1/observation-sessions/${sessionId}/perception-media/${resultId}/manifest`,
|
||||
manifest_generation_sha256: generation,
|
||||
byte_length: 12_345_678,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 35.421857292,
|
||||
timeline_end_seconds: 484.144857292,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
const decoded = decodeObservationSessionReplay(replay({
|
||||
session_id: sessionId,
|
||||
timeline_end_seconds: 500,
|
||||
media_sources: [source],
|
||||
}));
|
||||
assert.equal(decoded.mediaSources[0].id, "recorded.perception.right");
|
||||
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
|
||||
|
||||
const manifest = decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: source.id,
|
||||
generation_sha256: generation,
|
||||
byte_length: source.byte_length,
|
||||
timeline_start_seconds: source.timeline_start_seconds,
|
||||
timeline_end_seconds: source.timeline_end_seconds,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: source.timeline_start_seconds,
|
||||
timeline_end_seconds: source.timeline_end_seconds,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
byte_length: source.byte_length,
|
||||
stream_url: `/api/v1/observation-sessions/${sessionId}/perception-media/${resultId}/recording.mp4?generation=${generation}`,
|
||||
}],
|
||||
}, decoded.mediaSources[0]);
|
||||
assert.equal(manifest.epochs.length, 1);
|
||||
assert.equal(manifest.epochs[0].timelineStartSeconds, source.timeline_start_seconds);
|
||||
assert.equal(manifest.epochs[0].timelineEndSeconds, source.timeline_end_seconds);
|
||||
});
|
||||
|
||||
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
|
||||
const sessionId = "session-20260716T205632Z";
|
||||
const base = {
|
||||
|
|
@ -888,7 +977,7 @@ test("recorded camera contracts reject foreign origins, path escapes and unknown
|
|||
}));
|
||||
assert.throws(
|
||||
() => decodeObservationRecordedMediaManifest({
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: base.id,
|
||||
generation_sha256: "c".repeat(64),
|
||||
byte_length: 384,
|
||||
|
|
@ -900,17 +989,8 @@ test("recorded camera contracts reject foreign origins, path escapes and unknown
|
|||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: "file:///private/init.mp4",
|
||||
init_byte_length: 128,
|
||||
init_sha256: "a".repeat(64),
|
||||
segment_count: 1,
|
||||
segment_url_prefix: "file:///private/segments/",
|
||||
segments: [{
|
||||
sequence: 1,
|
||||
url: "file:///private/segments/1.m4s",
|
||||
byte_length: 256,
|
||||
sha256: "b".repeat(64),
|
||||
}],
|
||||
byte_length: 384,
|
||||
stream_url: "file:///private/recording.mp4",
|
||||
}],
|
||||
}, decoded.mediaSources[0]),
|
||||
/небезопасный API URL/,
|
||||
|
|
|
|||
|
|
@ -269,7 +269,14 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
{
|
||||
origin: "http://127.0.0.1:5174",
|
||||
activeView: "perception",
|
||||
activeView: "perception3d",
|
||||
viewResetGeneration: 1,
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
detections2d: true,
|
||||
segmentation: false,
|
||||
cuboids3d: true,
|
||||
},
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
|
|
@ -292,7 +299,12 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||
point_size: 4.5,
|
||||
palette: "custom",
|
||||
custom_color: "#35d7c1",
|
||||
active_view: "perception",
|
||||
active_view: "perception3d",
|
||||
view_reset_generation: 1,
|
||||
unified_perception: true,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
show_cuboids_3d: true,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
|
|
@ -665,7 +677,7 @@ test("camera delivery rejects literal and encoded endpoint or credential leaks",
|
|||
"/api/preview?upstream=rtsp://camera.local/live",
|
||||
"/api/preview?upstream=rtsp%3A%2F%2Fcamera.local%2Flive",
|
||||
"/api/preview?upstream=rtsp%253A%252F%252Fcamera.local%252Flive",
|
||||
"/api/preview?endpoint=192.168.68.52:8554",
|
||||
"/api/preview?endpoint=192.0.2.52:8554",
|
||||
"/api/preview?endpoint=192%2E168%2E68%2E52",
|
||||
"/api/preview?password=not-for-the-browser",
|
||||
"/api/preview?%70%61%73%73%77%6f%72%64=not-for-the-browser",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchVerifiedRecordedMediaArchive;
|
||||
let fetchRecordedMediaArchive;
|
||||
let recordedMediaPresentationState;
|
||||
let recordedMediaSeekableCoverage;
|
||||
let appendRecordedMediaBuffer;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
|
|
@ -18,10 +16,9 @@ before(async () => {
|
|||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchVerifiedRecordedMediaArchive,
|
||||
fetchRecordedMediaArchive,
|
||||
recordedMediaPresentationState,
|
||||
recordedMediaSeekableCoverage,
|
||||
appendRecordedMediaBuffer,
|
||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||
});
|
||||
|
||||
|
|
@ -29,41 +26,28 @@ after(async () => {
|
|||
await server?.close();
|
||||
});
|
||||
|
||||
function digest(payload) {
|
||||
return createHash("sha256").update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
function fixture({ byteLength = 36_000_000_000 } = {}) {
|
||||
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
|
||||
const init = Buffer.from("canonical-init");
|
||||
const first = Buffer.from("canonical-first-fragment");
|
||||
const second = Buffer.from("canonical-second-fragment");
|
||||
const generation = "a".repeat(64);
|
||||
const segmentPrefix = manifestUrl.replace("/manifest", "/epochs/1/segments/");
|
||||
const streamUrl = manifestUrl.replace(
|
||||
"/manifest",
|
||||
`/epochs/1/recording.mp4?generation=${generation}`,
|
||||
);
|
||||
const manifest = {
|
||||
schema_version: "missioncore.observation-recorded-media/v2",
|
||||
schema_version: "missioncore.observation-recorded-media/v3",
|
||||
source_id: "recorded.camera.camera-1",
|
||||
generation_sha256: generation,
|
||||
byte_length: init.byteLength + first.byteLength + second.byteLength,
|
||||
byte_length: byteLength,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
timeline_end_seconds: 36_000,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
epochs: [{
|
||||
ordinal: 1,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 20,
|
||||
timeline_end_seconds: 36_000,
|
||||
media_type: 'video/mp4; codecs="avc1.640028"',
|
||||
init_url: manifestUrl.replace("/manifest", "/epochs/1/init.mp4"),
|
||||
init_byte_length: init.byteLength,
|
||||
init_sha256: digest(init),
|
||||
segment_count: 2,
|
||||
segment_url_prefix: segmentPrefix,
|
||||
segments: [first, second].map((payload, index) => ({
|
||||
sequence: index + 1,
|
||||
url: `${segmentPrefix}${index + 1}.m4s`,
|
||||
byte_length: payload.byteLength,
|
||||
sha256: digest(payload),
|
||||
})),
|
||||
byte_length: byteLength,
|
||||
stream_url: streamUrl,
|
||||
}],
|
||||
};
|
||||
const source = {
|
||||
|
|
@ -72,14 +56,14 @@ function fixture() {
|
|||
modality: "video",
|
||||
manifestUrl,
|
||||
manifestGenerationSha256: generation,
|
||||
byteLength: manifest.byte_length,
|
||||
byteLength,
|
||||
mediaType: "video/mp4",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 20,
|
||||
timelineEndSeconds: 36_000,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
};
|
||||
return { source, manifest, generation, init, first, second };
|
||||
return { source, manifest, generation, streamUrl };
|
||||
}
|
||||
|
||||
function jsonResponse(payload, generation) {
|
||||
|
|
@ -92,145 +76,54 @@ function jsonResponse(payload, generation) {
|
|||
});
|
||||
}
|
||||
|
||||
function mediaResponse(payload, sha = digest(payload), length = payload.byteLength) {
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Length": String(length),
|
||||
ETag: `"sha256:${sha}"`,
|
||||
test("multi-hour camera admission fetches only its compact generation-bound manifest", async () => {
|
||||
const { source, manifest, generation, streamUrl } = fixture();
|
||||
const requested = [];
|
||||
const archive = await fetchRecordedMediaArchive(source, {
|
||||
fetcher: async (input, request = {}) => {
|
||||
requested.push(String(input));
|
||||
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(manifest, generation);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test("recorded camera archive stays pending until every canonical byte is verified", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const finalSegment = deferred();
|
||||
const requested = [];
|
||||
let manifestRequests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
const url = String(input);
|
||||
requested.push(url);
|
||||
const headers = new Headers(request.headers);
|
||||
if (url === source.manifestUrl) {
|
||||
manifestRequests += 1;
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(manifest, generation);
|
||||
}
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) {
|
||||
assert.equal(headers.get("If-Match"), `"sha256:${epoch.init_sha256}"`);
|
||||
return mediaResponse(init);
|
||||
}
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) return finalSegment.promise;
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
let settled = false;
|
||||
const archivePromise = fetchVerifiedRecordedMediaArchive(source, { fetcher })
|
||||
.then((archive) => {
|
||||
settled = true;
|
||||
return archive;
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
assert.equal(settled, false, "init and a partial segment prefix must not publish the camera");
|
||||
assert.deepEqual(requested.slice(0, 4), [
|
||||
source.manifestUrl,
|
||||
manifest.epochs[0].init_url,
|
||||
manifest.epochs[0].segments[0].url,
|
||||
manifest.epochs[0].segments[1].url,
|
||||
]);
|
||||
|
||||
finalSegment.resolve(mediaResponse(second));
|
||||
const archive = await archivePromise;
|
||||
assert.equal(manifestRequests, 2, "the immutable generation is revalidated after transfer");
|
||||
assert.equal(archive.byteLength, init.byteLength + first.byteLength + second.byteLength);
|
||||
assert.equal(archive.epochs[0].segments.length, 2);
|
||||
assert.deepEqual(requested, [source.manifestUrl]);
|
||||
assert.equal(archive.byteLength, 36_000_000_000);
|
||||
assert.equal(archive.manifest.epochs[0].streamUrl, streamUrl);
|
||||
});
|
||||
|
||||
test("first camera manifest request is launch-generation bound and rejects replacement", async () => {
|
||||
test("first camera manifest request rejects a replaced generation", async () => {
|
||||
const { source, manifest, generation } = fixture();
|
||||
const replacementGeneration = "d".repeat(64);
|
||||
let requests = 0;
|
||||
const fetcher = async (input, request = {}) => {
|
||||
requests += 1;
|
||||
assert.equal(String(input), source.manifestUrl);
|
||||
assert.equal(
|
||||
new Headers(request.headers).get("If-Match"),
|
||||
`"sha256:${generation}"`,
|
||||
);
|
||||
return jsonResponse(
|
||||
{ ...manifest, generation_sha256: replacementGeneration },
|
||||
replacementGeneration,
|
||||
);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
fetchRecordedMediaArchive(source, {
|
||||
fetcher: async (_input, request = {}) => {
|
||||
assert.equal(new Headers(request.headers).get("If-Match"), `"sha256:${generation}"`);
|
||||
return jsonResponse(
|
||||
{ ...manifest, generation_sha256: replacementGeneration },
|
||||
replacementGeneration,
|
||||
);
|
||||
},
|
||||
}),
|
||||
/несовместим|заменён/,
|
||||
);
|
||||
assert.equal(requests, 1);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed on truncation despite plausible headers", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(second.subarray(0, second.byteLength - 1), digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
test("camera manifest fails closed when epoch bytes do not match launch bytes", async () => {
|
||||
const { source, manifest, generation } = fixture();
|
||||
const mismatched = {
|
||||
...manifest,
|
||||
epochs: [{ ...manifest.epochs[0], byte_length: manifest.byte_length - 1 }],
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/усечён/,
|
||||
fetchRecordedMediaArchive(source, {
|
||||
fetcher: async () => jsonResponse(mismatched, generation),
|
||||
}),
|
||||
/не совпадает/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded camera archive fails closed when bytes are replaced under an old ETag", async () => {
|
||||
const { source, manifest, generation, init, first, second } = fixture();
|
||||
const replacement = Buffer.from(second.map((value) => value ^ 0xff));
|
||||
assert.equal(replacement.byteLength, second.byteLength);
|
||||
const fetcher = async (input) => {
|
||||
const url = String(input);
|
||||
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
|
||||
const epoch = manifest.epochs[0];
|
||||
if (url === epoch.init_url) return mediaResponse(init);
|
||||
if (url === epoch.segments[0].url) return mediaResponse(first);
|
||||
if (url === epoch.segments[1].url) {
|
||||
return mediaResponse(replacement, digest(second), second.byteLength);
|
||||
}
|
||||
throw new Error(`Unexpected URL ${url}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
|
||||
/SHA-256/,
|
||||
);
|
||||
});
|
||||
|
||||
test("camera presentation gate opens only for the completely appended selected epoch", () => {
|
||||
test("camera presentation gate opens only for the seekable selected epoch", () => {
|
||||
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
|
||||
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
|
||||
|
|
@ -251,40 +144,13 @@ test("decoded duration and seekable range cover the complete declared epoch", ()
|
|||
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
|
||||
});
|
||||
|
||||
test("aborting SourceBuffer append removes every temporary listener", async () => {
|
||||
const listeners = new Map();
|
||||
const sourceBuffer = {
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? new Set();
|
||||
bucket.add(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
appendBuffer() {},
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const pending = appendRecordedMediaBuffer(
|
||||
sourceBuffer,
|
||||
new ArrayBuffer(8),
|
||||
abort.signal,
|
||||
);
|
||||
assert.equal(listeners.get("updateend")?.size, 1);
|
||||
assert.equal(listeners.get("error")?.size, 1);
|
||||
abort.abort();
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
assert.equal(listeners.get("updateend")?.size, 0);
|
||||
assert.equal(listeners.get("error")?.size, 0);
|
||||
});
|
||||
|
||||
test("verified camera archives remain immutable for safe player remount", async () => {
|
||||
test("recorded player range-streams and never builds a whole-video RAM Blob", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(source, /\.init\s*=\s*new ArrayBuffer/);
|
||||
assert.doesNotMatch(source, /\.segments\.length\s*=\s*0/);
|
||||
assert.match(source, /video\.src\s*=\s*descriptor\.streamUrl/);
|
||||
assert.doesNotMatch(source, /new Blob\(|response\.arrayBuffer\(|SourceBuffer/);
|
||||
});
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
|
|
@ -301,3 +167,20 @@ test("loading and error overlays fully conceal recorded camera pixels", async ()
|
|||
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/);
|
||||
assert.match(source, /hidden=\{pointCloudFocused \|\| unifiedPerception\}/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\{!pointCloudFocused \? visibleMediaSources\.map/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\(pointCloudFocused \|\| !observationLayout\.visibleSourceIds\.has\(source\.id\)\)/,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,34 +78,27 @@ test("any RRD or camera failure closes the complete recorded session", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("camera admission enforces independent 16/256/512 MiB limits before fetching", () => {
|
||||
test("camera admission keeps source scheduling bounded but has no duration or byte ceiling", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
MAX_RECORDED_SESSION_CAMERA_BYTES,
|
||||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 256 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
byteLength: 36_000_000_000,
|
||||
})),
|
||||
), "ready");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 17 }, (_, index) => ({ id: `camera.${index}`, byteLength: 1 })),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.large", byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES + 1 },
|
||||
{ id: "camera.ten-hours", byteLength: 36_000_000_000 },
|
||||
]), "ready");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.invalid", byteLength: Number.MAX_SAFE_INTEGER + 1 },
|
||||
]), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: 110 * 1024 * 1024,
|
||||
})),
|
||||
), "error");
|
||||
assert.equal(recordedCameraDescriptorPreflight([
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
{ id: "camera.duplicate", byteLength: 1 },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
||||
|
||||
const sha256 = (path) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
|
||||
test("NODE.DC Rerun runtime is the audited 0.34.1 zoom-to-cursor build", () => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
assert.equal(manifest.version, "0.34.1");
|
||||
|
||||
const expectedWasm = "38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb";
|
||||
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
|
||||
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
|
||||
});
|
||||
|
||||
test("custom JavaScript glue references only exports present in its paired WASM", () => {
|
||||
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm");
|
||||
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js");
|
||||
const module = new WebAssembly.Module(readFileSync(wasmPath));
|
||||
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
|
||||
const imports = WebAssembly.Module.imports(module);
|
||||
const glue = readFileSync(gluePath, "utf8");
|
||||
const referencedExports = new Set(
|
||||
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
|
||||
);
|
||||
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
|
||||
|
||||
assert.equal(imports.length, 927);
|
||||
assert.equal(exports.size, 79);
|
||||
assert.deepEqual(missingExports, []);
|
||||
assert.match(glue, /export default function\(\)/);
|
||||
assert.match(glue, /if \(!wasm\) return;/);
|
||||
});
|
||||
|
||||
test("custom Rerun WASM initializes and grows its externref table", () => {
|
||||
const runtime = makeRerunRuntime();
|
||||
runtime.initSync({
|
||||
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
|
||||
});
|
||||
|
||||
assert.equal(typeof runtime.WebHandle, "function");
|
||||
runtime.deinit();
|
||||
});
|
||||
|
||||
test("source patch carries cursor pivot, minimum-radius handoff, and geometry tests", () => {
|
||||
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
|
||||
|
||||
assert.match(patch, /fn pointer_ray_direction/);
|
||||
assert.match(patch, /fn zoom_orbit_towards_pointer/);
|
||||
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
|
||||
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
|
||||
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
|
||||
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
|
||||
});
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
# NODE.DC Rerun web viewer 0.34.1
|
||||
|
||||
This directory contains the audited Mission Core camera-controller override for
|
||||
`@rerun-io/web-viewer` 0.34.1. It changes only the native orbital zoom behavior:
|
||||
|
||||
- the pointer ray selects an anchor on the current focus plane;
|
||||
- eye position and look target scale around that anchor, so the point under the
|
||||
cursor remains under the cursor;
|
||||
- after Rerun's `0.02 m` near-plane safety radius is reached, excess zoom becomes
|
||||
a cursor-directed dolly scaled by the scene's navigation speed instead of
|
||||
silently ignoring the wheel or moving by imperceptible millimeters;
|
||||
- first-person movement, orbit rotation, panning, WASD and Rerun's zoom-out cap
|
||||
are unchanged.
|
||||
|
||||
## Source identity
|
||||
|
||||
- Upstream: `rerun-io/rerun`
|
||||
- Tag: `0.34.1`
|
||||
- Commit: `4efb18f17f6f0e41985cda99a2bdcd012febc8d5`
|
||||
- Patched file: `crates/viewer/re_view_spatial/src/eye.rs`
|
||||
- Patch: `NODEDC_ZOOM_TO_CURSOR.patch`
|
||||
- Rust: `1.92.0`
|
||||
- Binaryen / `wasm-opt`: `117` (the version pinned by Rerun's `pixi.lock`)
|
||||
- Build image: `rust:1.92-bookworm`
|
||||
- Build image digest:
|
||||
`sha256:e90e846de4124376164ddfbaab4b0774c7bdeef5e738866295e5a90a34a307a2`
|
||||
- Build date: `2026-07-22` (`Europe/Moscow`)
|
||||
|
||||
## Reproduction
|
||||
|
||||
Apply the patch to the exact upstream commit, then run Rerun's own builder:
|
||||
|
||||
```sh
|
||||
git apply NODEDC_ZOOM_TO_CURSOR.patch
|
||||
cargo test -p re_view_spatial --lib eye::tests:: -- --nocapture
|
||||
cargo run -p re_dev_tools -- build-web-viewer \
|
||||
--release -g \
|
||||
--target no-modules-base \
|
||||
--no-default-features \
|
||||
--features map_view \
|
||||
-o rerun_js/web-viewer
|
||||
```
|
||||
|
||||
The container also needs Binaryen `117` for Rerun's final `wasm-opt -O2` step.
|
||||
Binaryen `108` from Debian 12 must not be used: it produced a module whose
|
||||
`externref` table could not grow during initialization. The
|
||||
generated `re_viewer.js` is transformed with
|
||||
`scripts/transform-rerun-web-viewer-glue.mjs`, which mirrors Rerun 0.34.1's
|
||||
`rerun_js/web-viewer/build-wasm.mjs` no-modules wrapper and teardown guards.
|
||||
|
||||
## Verified artifacts
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| --- | --- |
|
||||
| `re_viewer_bg.nodedc.wasm` | `38d19bac06b7c3b8e549489469cf7c4a24f319ec953849e4656b5827a6c105bb` |
|
||||
| raw generated `re_viewer.js` | `cc196a93c5be972c801d46be4dc9934f7f042eb62941f0aa0678f1c8416c6874` |
|
||||
| `re_viewer.nodedc.js` | `0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339` |
|
||||
|
||||
The focused Rust suite completed with `5 passed, 0 failed`, including the
|
||||
orbit-to-dolly boundary case. A Node `initSync`
|
||||
smoke test completed successfully, including the `externref` table-growth step.
|
||||
The Mission Core Node suite also checks both artifact hashes and verifies that
|
||||
every `wasm.*` reference in the JavaScript glue exists in the paired WASM
|
||||
exports.
|
||||
|
||||
`scripts/patch-rerun-web-viewer.mjs` installs the pair after npm extracts the
|
||||
official package. It accepts only the known published or NODE.DC hashes and
|
||||
fails closed on any other package contents.
|
||||
230
apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch
vendored
Normal file
230
apps/control-station/vendor/rerun-web-viewer-0.34.1/NODEDC_ZOOM_TO_CURSOR.patch
vendored
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
diff --git a/crates/viewer/re_view_spatial/src/eye.rs b/crates/viewer/re_view_spatial/src/eye.rs
|
||||
index e59b311..3f33259 100644
|
||||
--- a/crates/viewer/re_view_spatial/src/eye.rs
|
||||
+++ b/crates/viewer/re_view_spatial/src/eye.rs
|
||||
@@ -512,8 +512,85 @@ impl EyeController {
|
||||
}
|
||||
}
|
||||
|
||||
+ /// Returns the world-space ray under the pointer.
|
||||
+ ///
|
||||
+ /// Keeping this calculation local to the eye controller lets orbital zoom use the pointer
|
||||
+ /// synchronously. GPU picking arrives a frame later and would make the zoom pivot visibly lag.
|
||||
+ fn pointer_ray_direction(&self, rect: Rect, pointer: egui::Pos2) -> Option<Vec3> {
|
||||
+ if !rect.contains(pointer) || rect.width() <= 0.0 || rect.height() <= 0.0 {
|
||||
+ return None;
|
||||
+ }
|
||||
+
|
||||
+ let fov_y = self.fov_y.unwrap_or(Eye::DEFAULT_FOV_Y);
|
||||
+ let aspect_ratio = rect.width() / rect.height();
|
||||
+ let focal_scale = (fov_y * 0.5).tan();
|
||||
+ let x = (2.0 * (pointer.x - rect.left()) / rect.width() - 1.0)
|
||||
+ * focal_scale
|
||||
+ * aspect_ratio;
|
||||
+ let y = (1.0 - 2.0 * (pointer.y - rect.top()) / rect.height()) * focal_scale;
|
||||
+
|
||||
+ (self.rotation() * vec3(x, y, -1.0)).try_normalize()
|
||||
+ }
|
||||
+
|
||||
+ /// Zoom an orbital eye around the point under the pointer on the current focus plane.
|
||||
+ ///
|
||||
+ /// The position and look target are scaled around the same anchor. This preserves the
|
||||
+ /// projected pointer position while retaining the existing orbit direction and controls.
|
||||
+ /// Once the near-plane safety radius is reached, excess zoom becomes a cursor-directed dolly
|
||||
+ /// measured in the scene's navigation speed instead of near-plane millimeters.
|
||||
+ fn zoom_orbit_towards_pointer(
|
||||
+ &mut self,
|
||||
+ zoom_factor: f32,
|
||||
+ max_radius: f32,
|
||||
+ rect: Rect,
|
||||
+ pointer: Option<egui::Pos2>,
|
||||
+ ) {
|
||||
+ let radius = self.radius();
|
||||
+ if !radius.is_finite() || radius <= f32::MIN_POSITIVE {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ let requested_radius = radius / zoom_factor;
|
||||
+ let new_radius = requested_radius.clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
|
||||
+ let scale = new_radius / radius;
|
||||
+
|
||||
+ let ray_direction = pointer.and_then(|pointer| self.pointer_ray_direction(rect, pointer));
|
||||
+ if let Some(ray_direction) = ray_direction {
|
||||
+ let forward = self.fwd();
|
||||
+ let denominator = ray_direction.dot(forward);
|
||||
+ if denominator > 1.0e-4 {
|
||||
+ let anchor = self.pos + ray_direction * (radius / denominator);
|
||||
+ self.pos = anchor + (self.pos - anchor) * scale;
|
||||
+ self.look_target = anchor + (self.look_target - anchor) * scale;
|
||||
+
|
||||
+ if requested_radius < Self::MIN_ORBIT_DISTANCE {
|
||||
+ // Shrinking the remaining 2 cm orbit radius consumes only part of this input.
|
||||
+ // Hand the logarithmic remainder to the same scene-scaled speed used by WASD
|
||||
+ // and first-person scroll. Basing this on the near-plane remainder itself made
|
||||
+ // each wheel event move by millimeters and felt indistinguishable from a hard
|
||||
+ // zoom limit on building- and map-scale recordings.
|
||||
+ let orbit_zoom_factor = (radius / Self::MIN_ORBIT_DISTANCE).max(1.0);
|
||||
+ let remaining_zoom_factor = zoom_factor / orbit_zoom_factor;
|
||||
+ if remaining_zoom_factor > 1.0 && remaining_zoom_factor.is_finite() {
|
||||
+ let dolly = remaining_zoom_factor.ln() * self.speed as f32;
|
||||
+ self.pos += ray_direction * dolly;
|
||||
+ self.look_target += ray_direction * dolly;
|
||||
+ }
|
||||
+ }
|
||||
+ self.did_interact = true;
|
||||
+ return;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Pointer data can be absent for synthetic zoom events. Preserve Rerun's centered zoom
|
||||
+ // behavior in that case instead of dropping the input.
|
||||
+ self.pos = self.look_target - self.fwd() * new_radius;
|
||||
+ self.did_interact = true;
|
||||
+ }
|
||||
+
|
||||
/// Handle zoom/scroll input.
|
||||
- fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
|
||||
+ fn handle_zoom(&mut self, response: &egui::Response, scene_bounding_box: &macaw::BoundingBox) {
|
||||
+ let egui_ctx = &response.ctx;
|
||||
let zoom_factor = egui_ctx.input(|input| {
|
||||
// egui's default horizontal_scroll_modifier is shift, which is also our speed-up modifier.
|
||||
// This means that a user who wants to speed up scroll-to-zoom will generate a horizontal scroll delta.
|
||||
@@ -528,22 +605,12 @@ impl EyeController {
|
||||
|
||||
match self.kind {
|
||||
Eye3DKind::Orbital => {
|
||||
- let radius = self.pos.distance(self.look_target);
|
||||
-
|
||||
// Cap zoom-out against the scene bounding box. If we're already past the cap
|
||||
// (e.g. right after loading) use the current radius instead — no snap-back.
|
||||
+ let radius = self.radius();
|
||||
let max_radius = max_orbital_radius(scene_bounding_box).max(radius);
|
||||
- let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
|
||||
-
|
||||
- // The user may be scrolling to move the camera closer, but are not realizing
|
||||
- // the radius is now tiny.
|
||||
- // TODO(emilk): inform the users somehow that scrolling won't help, and that they should use WSAD instead.
|
||||
- // It might be tempting to start moving the camera here on scroll, but that would is bad for other reasons.
|
||||
-
|
||||
- if f32::MIN_POSITIVE < new_radius {
|
||||
- self.pos = self.look_target - self.fwd() * new_radius;
|
||||
- self.did_interact = true;
|
||||
- }
|
||||
+ let pointer = response.ctx.pointer_latest_pos();
|
||||
+ self.zoom_orbit_towards_pointer(zoom_factor, max_radius, response.rect, pointer);
|
||||
}
|
||||
Eye3DKind::FirstPerson => {
|
||||
// Move along the forward axis when zooming in first person mode.
|
||||
@@ -687,7 +754,7 @@ impl EyeController {
|
||||
self.handle_drag(response, drag_threshold);
|
||||
|
||||
if response.hovered() {
|
||||
- self.handle_zoom(&response.ctx, scene_bounding_box);
|
||||
+ self.handle_zoom(response, scene_bounding_box);
|
||||
}
|
||||
|
||||
if response.has_focus() {
|
||||
@@ -1249,3 +1316,103 @@ impl EyeState {
|
||||
Ok(eye)
|
||||
}
|
||||
}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ fn orbital_controller(pos: Vec3, look_target: Vec3) -> EyeController {
|
||||
+ EyeController {
|
||||
+ pos,
|
||||
+ look_target,
|
||||
+ kind: Eye3DKind::Orbital,
|
||||
+ speed: 1.0,
|
||||
+ eye_up: Vec3::Z,
|
||||
+ fov_y: Some(Eye::DEFAULT_FOV_Y),
|
||||
+ did_interact: false,
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ fn test_rect() -> Rect {
|
||||
+ Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))
|
||||
+ }
|
||||
+
|
||||
+ fn assert_vec3_close(actual: Vec3, expected: Vec3) {
|
||||
+ assert!(
|
||||
+ actual.abs_diff_eq(expected, 1.0e-5),
|
||||
+ "actual={actual:?}, expected={expected:?}"
|
||||
+ );
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn centered_pointer_keeps_the_orbit_target() {
|
||||
+ let rect = test_rect();
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert_vec3_close(controller.pos, vec3(0.0, -5.0, 0.0));
|
||||
+ assert_vec3_close(controller.look_target, Vec3::ZERO);
|
||||
+ assert!(controller.did_interact);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn off_center_pointer_stays_on_the_same_view_ray() {
|
||||
+ let rect = test_rect();
|
||||
+ let pointer = egui::pos2(650.0, 240.0);
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
|
||||
+ let original_ray = controller
|
||||
+ .pointer_ray_direction(rect, pointer)
|
||||
+ .expect("test pointer must produce a ray");
|
||||
+ let forward = controller.fwd();
|
||||
+ let anchor = controller.pos
|
||||
+ + original_ray * (controller.radius() / original_ray.dot(forward));
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(pointer));
|
||||
+
|
||||
+ assert!((controller.radius() - 5.0).abs() < 1.0e-5);
|
||||
+ let ray_after = (anchor - controller.pos).normalize();
|
||||
+ assert!(ray_after.dot(original_ray) > 0.99999);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn near_limit_hands_excess_zoom_to_cursor_directed_dolly() {
|
||||
+ let rect = test_rect();
|
||||
+ let radius = EyeController::MIN_ORBIT_DISTANCE;
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -radius, 0.0), Vec3::ZERO);
|
||||
+ let old_pos = controller.pos;
|
||||
+ let old_target = controller.look_target;
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(2.0, 100.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert!((controller.radius() - radius).abs() < 1.0e-6);
|
||||
+ assert_vec3_close(controller.pos, old_pos + Vec3::Y * 2.0_f32.ln());
|
||||
+ assert_vec3_close(
|
||||
+ controller.look_target,
|
||||
+ old_target + Vec3::Y * 2.0_f32.ln(),
|
||||
+ );
|
||||
+ assert_vec3_close(controller.look_target - controller.pos, Vec3::Y * radius);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn crossing_near_limit_preserves_unconsumed_scene_scaled_zoom() {
|
||||
+ let rect = test_rect();
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -0.03, 0.0), Vec3::ZERO);
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(3.0, 100.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert!((controller.radius() - EyeController::MIN_ORBIT_DISTANCE).abs() < 1.0e-6);
|
||||
+ assert_vec3_close(controller.pos, vec3(0.0, 2.0_f32.ln() - 0.02, 0.0));
|
||||
+ assert_vec3_close(controller.look_target, vec3(0.0, 2.0_f32.ln(), 0.0));
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn zoom_out_respects_scene_radius_cap() {
|
||||
+ let rect = test_rect();
|
||||
+ let mut controller = orbital_controller(vec3(0.0, -10.0, 0.0), Vec3::ZERO);
|
||||
+
|
||||
+ controller.zoom_orbit_towards_pointer(0.1, 25.0, rect, Some(rect.center()));
|
||||
+
|
||||
+ assert!((controller.radius() - 25.0).abs() < 1.0e-5);
|
||||
+ }
|
||||
+}
|
||||
File diff suppressed because it is too large
Load Diff
BIN
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm (Stored with Git LFS)
vendored
Normal file
BIN
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm (Stored with Git LFS)
vendored
Normal file
Binary file not shown.
|
|
@ -53,6 +53,13 @@ export default defineConfig(({ mode }) => {
|
|||
host: "127.0.0.1",
|
||||
port: 4173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
This plan supersedes the app-dependent experiment order in the reference Bible.
|
||||
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||
|
||||
## Current checkpoint — 2026-07-19
|
||||
## Current checkpoint — 2026-07-20
|
||||
|
||||
| Stage | Result |
|
||||
| --- | --- |
|
||||
|
|
@ -11,7 +11,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
|||
| Gate 1 physical operation | GO — autonomous double-click start/stop verified |
|
||||
| Stage 1 BLE discovery | GO — repeatable advertisement and GATT profile |
|
||||
| Stage 2 BLE provisioning | GO — reviewed 99-byte profile, LAN association confirmed |
|
||||
| Stage 2 local connection matrix | GO (implementation) — Bridge remains physically accepted; Direct Connect reuses the reviewed one-shot BLE frame; Quick Connect uses one CoreWLAN host association and no GATT write. Direct Connect and Mission Core Quick Connect still require separate physical acceptance |
|
||||
| Stage 2 local connection matrix | GO for Bridge/direct-LAN; LAB-ONLY for Quick Connect — firmware review proved the K1 3.0.2 AP credential is firmware-constant and the SSID follows the `XGR-` identity/MAC rule. One prepared Mac completed AP-enable, AP-ready, CoreWLAN association and the normal control lifecycle. The successful host had been seeded from the reviewed firmware archive; a copied build on a clean host cannot acquire that material automatically. Automatic firmware download, iPhone extraction and hard-coding were rejected. Bridge remains the product path; Direct Connect remains physically pending |
|
||||
| Stage 3 application session | GO — MQTT 3.1.1 on confirmed K1 TCP 1883 |
|
||||
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
|
||||
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,35 @@ profile is not a generic XGRIDS protocol claim and must not be used for fuzzing.
|
|||
- The application sends operator-supplied router credentials; it does not derive
|
||||
them from the K1 and it does not require the password of the K1's own AP.
|
||||
|
||||
The 99-byte `7f01` profile is used when K1 joins an external network (Bridge or
|
||||
controller hotspot). It is not the Quick Connect bootstrap. A live Mission Core
|
||||
negative run on 2026-07-19 proved that host association alone cannot work while
|
||||
the reviewed K1 remains in station mode: `7f02` still reported the existing LAN.
|
||||
|
||||
Re-review of the owner-supplied LixelGO QuickLink branch then recovered the
|
||||
missing first step. After a real BLE connection, LixelGO sends one fixed
|
||||
100-byte frame to the same `7f01` characteristic: bytes `0..98` are zero and
|
||||
byte `99` is `0x01`. Its callback is the AP-launch result; only then does the
|
||||
application passes that device record's `WiFiAP_SSID` and `WiFiAP_Password` to
|
||||
the operating-system connector. Later analysis of the official K1 `3.0.2`
|
||||
firmware recovered the upstream source: `lixel_nman` invokes the bundled
|
||||
NetworkManager AP script, which assigns a firmware-constant WPA2 credential.
|
||||
The persisted app fields are per-device records, but the reviewed firmware
|
||||
material is not per-device. The reviewed app requests write-without-response.
|
||||
Mission Core follows the live characteristic properties on macOS, where this K1
|
||||
advertises write-with-response, and never retries either transport or command
|
||||
automatically.
|
||||
|
||||
The browser/API carries no AP password. An optional laboratory importer authenticates the
|
||||
exact official `3.0.2` archive, resolves the reviewed material without printing
|
||||
it and installs one firmware-scoped credential source in the OS secure store.
|
||||
Mission Core then binds a device-scoped host profile ID to the selected
|
||||
BLE-advertised SSID. Before any device write, the macOS helper materializes that
|
||||
profile entirely inside Keychain from the firmware-scoped source. The actual
|
||||
secret never reaches the browser, API, argv, logs, manifests or evidence. The
|
||||
bounded Python importer holds it only in a short-lived mutable buffer, sends it
|
||||
through helper stdin and zeroizes the buffer.
|
||||
|
||||
Full UUIDs:
|
||||
|
||||
- service: `00007f00-0000-1000-8000-00805f9b34fb`;
|
||||
|
|
@ -38,6 +67,59 @@ There is no checksum, nonce, token, certificate, signature, or separate commit
|
|||
command in this production call path. The implementation must never print,
|
||||
persist, or accept the password as a command-line argument.
|
||||
|
||||
## Quick Connect AP-enable frame
|
||||
|
||||
The separately reviewed Quick Connect device action writes exactly 100 bytes:
|
||||
|
||||
| Offset | Length | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| 0 | 99 | zero |
|
||||
| 99 | 1 | enable device AP (`0x01`) |
|
||||
|
||||
It contains no SSID, password, device identifier or user input. The reviewed
|
||||
client maps response byte 51 of `7f02` to its AP-ready flag. Mission Core
|
||||
therefore requires all three observations before host association: mode
|
||||
`WIFI_AP`, address `192.168.56.1`, and non-zero byte 51. A failed or ambiguous
|
||||
attempt is not automatically repeated.
|
||||
|
||||
The first controlled AP attempt was physically accepted: one write-with-response
|
||||
completed, `7f02` changed to `WIFI_AP / 192.168.56.1`, and a later exact
|
||||
CoreWLAN scan found the SSID matching the selected BLE device. The Mac remained
|
||||
on its previous LAN only because the former host adapter looked up an invalid
|
||||
global profile. That profile inference and its APK importer were withdrawn.
|
||||
|
||||
A later physical attempt established another boundary: `7f02` can continue to
|
||||
report `WIFI_AP / 192.168.56.1` with byte 51 zero after the exact SSID is no
|
||||
longer beaconing. Mode and address alone are therefore not an idempotency or
|
||||
readiness signal. Like the reviewed LixelGO action, each new explicit Mission
|
||||
Core Quick Connect intent emits one AP-enable frame even when the baseline mode
|
||||
already says `WIFI_AP`, then polls for the byte-51 ready flag for at most 15
|
||||
seconds. It never retries the device write automatically.
|
||||
|
||||
Static review of the original client also established a lifecycle requirement:
|
||||
LixelGO keeps the same BLE manager connected after AP-ready and invokes native
|
||||
Wi-Fi association from that live session. Mission Core now retains the same
|
||||
`BleakClient` while CoreWLAN performs bounded exact-SSID discovery and one
|
||||
association. The discovery window may contain multiple read-only scans; it does
|
||||
not repeat the BLE write or Wi-Fi association.
|
||||
|
||||
The next physical attempt exposed a CoreBluetooth lifecycle boundary before any
|
||||
write: K1 appeared in the explicit six-second BLE scan, but a second lookup by
|
||||
its macOS UUID failed moments later. The UUID is a transport-local selector, not
|
||||
a durable rediscovery contract. The runtime now retains the live `BLEDevice`
|
||||
handle from the operator's scan and connects that exact selected handle in the
|
||||
following network action. A fallback lookup remains only for non-UI callers
|
||||
that did not perform discovery first.
|
||||
|
||||
The 2026-07-20 prepared-host acceptance installed the exact firmware provider,
|
||||
found one expected K1 candidate, emitted one AP-enable write, observed AP-ready
|
||||
and completed one CoreWLAN association without an iPhone or manual credential.
|
||||
Mission Core admitted `192.168.56.1`; the operator disconnected afterward only
|
||||
to restore the external chat route. Full redacted evidence and artifact hashes
|
||||
are recorded in `docs/lab/004_K1_FW302_AP_CREDENTIAL_PROVIDER_20260720.redacted.md`.
|
||||
This proves the mechanism on that Mac, not automatic credential acquisition by
|
||||
a copied application on a clean host.
|
||||
|
||||
The Android application unequivocally requests a write without response and
|
||||
negotiates MTU 120, making the 99-byte frame one ATT command. A live read-only
|
||||
CoreBluetooth check reports MTU 256 and a maximum write-without-response size of
|
||||
|
|
@ -53,11 +135,13 @@ mode. It never fragments or retries the payload automatically.
|
|||
## Expected transition and evidence of acceptance
|
||||
|
||||
A completed GATT write only proves transport completion. It does not prove that
|
||||
the K1 joined Wi-Fi. The application polls `7f02`; the observed response frame
|
||||
contains a fixed-width mode slot, an address slot, and a status byte at offset
|
||||
50. The current AP baseline reports mode `WIFI_AP` and address `192.168.56.1`.
|
||||
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
|
||||
observed response frame contains a fixed-width mode slot, an address slot, a
|
||||
status byte at offset 50 and the AP-ready flag at offset 51. The stale AP
|
||||
baseline reports `WIFI_AP / 192.168.56.1 / byte51=0`; the physically observed
|
||||
ready transition reports the same mode/address with `byte51=1`.
|
||||
|
||||
For the controlled experiment, acceptance required at least one of:
|
||||
For Bridge/Direct Connect, acceptance requires at least one of:
|
||||
|
||||
1. `7f02` reports a non-AP IPv4 address;
|
||||
2. the same address appears as a new router/ARP client after the write;
|
||||
|
|
@ -66,10 +150,35 @@ For the controlled experiment, acceptance required at least one of:
|
|||
|
||||
Do not infer success from a write callback alone.
|
||||
|
||||
The Bridge/Direct Connect address is a DHCP lease, not configuration and not
|
||||
device identity. Mission Core re-reads `7f02` without writing before every new
|
||||
LAN control session, implicit-host acquisition and factory-calibration read.
|
||||
If the value changes, it rotates `device_session_id`; it never retargets an
|
||||
active acquisition. A correlated MQTT `DeviceInfo` response supplies the live
|
||||
model/firmware/serial identity barrier.
|
||||
|
||||
The 2026-07-20 reboot/power-cycle check observed the startup race directly:
|
||||
one read returned the earlier `.54` lease while that exact address had no ARP or
|
||||
application endpoint; a later read returned `.52`, where exact probes found
|
||||
MQTT 1883 and RTSP 8554. No subnet scan, route change, network-profile change or
|
||||
VPN action was used. This is why no owner-LAN IPv4 value is a product constant
|
||||
and why a BLE lease observation alone is not reported as live DeviceInfo.
|
||||
|
||||
For Quick Connect, host association is not admitted until the canonical
|
||||
byte-51 ready flag is observed. CoreWLAN then searches only for the exact
|
||||
device-profile SSID for at most 15 seconds and performs at most one association.
|
||||
|
||||
## Safety, recovery and stop conditions
|
||||
|
||||
- Perform one write per explicitly named attempt, using credentials for the LAN
|
||||
already used by the Mac. Never retry automatically.
|
||||
- Perform one write per explicitly named attempt. Never retry automatically.
|
||||
- For Bridge/Direct Connect, use credentials for the operator-selected network.
|
||||
- For Quick Connect, the exact `3.0.2` provider must already exist in the OS
|
||||
secure store. Missing or mismatched firmware material fails before the AP
|
||||
write. Never extrapolate this provider to another firmware or model.
|
||||
- The macOS adapter materializes a device-scoped Keychain item from the exact
|
||||
firmware source, then performs one association. Standard Wi-Fi Keychain and
|
||||
native prompt paths remain compatibility fallbacks, not the reviewed
|
||||
zero-touch path. It never asks the browser for a password.
|
||||
- Do not alter Deco settings, scan the subnet, or guess any credential.
|
||||
- If the status does not change, do not retry automatically.
|
||||
- If the supplied credentials are wrong, reconnect over BLE and overwrite them
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ fully usable with only their available modalities.
|
|||
1. Enter the required project name and start a normal acquisition from **Парк →
|
||||
Локальное устройство**. The NFKC-normalized/trimmed value is local display
|
||||
metadata, not a filesystem path, and becomes the saved-session catalog name.
|
||||
A normal acquisition has no duration deadline and runs until the operator
|
||||
explicitly stops it. Compatibility clients may still request a positive
|
||||
finite duration, without an application-level maximum.
|
||||
2. Open **Наблюдение → Пространственная сцена**. Live point cloud, trajectory
|
||||
and the selected camera remain live-only while acquisition is running.
|
||||
3. Stop acquisition normally, or allow the local service to recover an
|
||||
|
|
@ -165,7 +168,8 @@ a separately attested storage root:
|
|||
export MISSIONCORE_DATA_DIR=/absolute/private/path/mission-core
|
||||
# Full K1 point-plus-camera evidence must currently remain below the checkout.
|
||||
export MISSIONCORE_EVIDENCE_DIR=/absolute/path/to/NODEDC_MISSION_CORE/.runtime/mission-core/evidence/sessions
|
||||
export MISSIONCORE_RRD_CACHE_MAX_BYTES=8589934592
|
||||
# Optional operator retention quota; unset means no application byte quota.
|
||||
# export MISSIONCORE_RRD_CACHE_MAX_BYTES=8589934592
|
||||
export MISSIONCORE_RRD_FREE_SPACE_RESERVE_BYTES=2147483648
|
||||
uv run k1link serve
|
||||
```
|
||||
|
|
@ -226,13 +230,15 @@ keeps the current device scan generation and does not accumulate an earlier
|
|||
distance after the device resets scan time and route distance. These values are
|
||||
not reconstructed from pose integration or a browser timer.
|
||||
|
||||
Expensive cache misses run through the bounded preparation worker and one global
|
||||
cross-process export gate to cap peak RAM, CPU and temporary-disk use. Crash
|
||||
leftovers from candidates, exporter temporary files and staged replay prefixes
|
||||
are scavenged under that lock before quota accounting. Ready cache hits and
|
||||
active response leases do not wait behind that gate. The derived cache has an
|
||||
8 GiB default quota, preserves a 2 GiB default filesystem reserve and evicts
|
||||
least-recently-used RRDs only; it never deletes native evidence.
|
||||
Expensive cache misses run through the single-worker preparation queue and one
|
||||
global cross-process export gate to cap concurrent RAM, CPU and temporary-disk
|
||||
use. Crash leftovers from candidates, exporter temporary files and staged replay
|
||||
prefixes are scavenged under that lock before capacity accounting. Ready cache
|
||||
hits and active response leases do not wait behind that gate. The derived cache
|
||||
has no application byte quota by default, so a single multi-hour RRD is not
|
||||
rejected at 8 GiB. It still preserves a 2 GiB default filesystem reserve. An
|
||||
operator may set `MISSIONCORE_RRD_CACHE_MAX_BYTES` to enable LRU eviction of
|
||||
derived RRDs only; native evidence is never deleted.
|
||||
|
||||
## Capture-clock envelope
|
||||
|
||||
|
|
@ -313,7 +319,9 @@ time are not proven to use a shared device clock. Do not infer frame-accurate
|
|||
calibration from the playback timeline.
|
||||
|
||||
Finalized media is prepared once by the same process-owned background job that
|
||||
materializes the RRD. The gateway records host time only after a complete
|
||||
materializes the RRD. JSONL indexes are read incrementally and no total index,
|
||||
segment-count or archive-byte ceiling is used. The gateway records host time
|
||||
only after a complete
|
||||
`moof+mdat` fragment has arrived, so that timestamp is an availability/end
|
||||
anchor, never a fragment-start timestamp. Preparation reads and SHA-verifies
|
||||
every fragment, parses bounded ISO-BMFF timing tables (`mdhd`, `trex`, `tfhd`,
|
||||
|
|
@ -332,7 +340,7 @@ coverage can be navigated. A missing, ambiguous, oversized or otherwise
|
|||
unparseable timing table fails preparation; the archive remains evidence but is
|
||||
never advertised as seekable media.
|
||||
|
||||
The prepared path-free v2 descriptor and full source stat identity (native raw
|
||||
The durable path-free v2 descriptor and full source stat identity (native raw
|
||||
and timing metadata plus every camera summary, index, init and segment file) are
|
||||
written under the private derived cache with a schema, generation and checksum.
|
||||
Publication uses a private temporary file, file and directory `fsync`, and atomic
|
||||
|
|
@ -379,8 +387,9 @@ camera acceptance run retained point/pose evidence only.
|
|||
provisional `transport` generation, but discovery will not use either to
|
||||
advertise camera coverage. A mismatched origin/envelope/summary fails closed.
|
||||
- A native capture with a missing final summary is accepted only when the raw
|
||||
and metadata prefix is bounded, aligned and structurally valid. It is cataloged
|
||||
as `interrupted`, never silently promoted to `ready`.
|
||||
and metadata prefix is aligned and structurally valid. Recovery streams the
|
||||
metadata JSONL one row at a time with no total-byte or message-count ceiling;
|
||||
it is cataloged as `interrupted`, never silently promoted to `ready`.
|
||||
- A non-newline metadata crash tail can be ignored. Newline-terminated or
|
||||
mid-file corruption fails closed.
|
||||
- Camera recovery retains a contiguous valid segment prefix, quarantines
|
||||
|
|
@ -426,6 +435,7 @@ POST /api/v1/observation-sessions/{id}/blueprint.rrd
|
|||
GET /api/v1/observation-sessions/{id}/media/{artifact}/manifest
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/init.mp4
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/segments/{m}.m4s
|
||||
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/recording.mp4?generation=<sha256>
|
||||
|
||||
GET /api/v1/workspace-layouts/observation.spatial
|
||||
PUT /api/v1/workspace-layouts/observation.spatial
|
||||
|
|
@ -461,40 +471,40 @@ revision in `If-Match`; stale writers receive HTTP 412 rather than overwriting
|
|||
another saved profile.
|
||||
|
||||
Recorded media routes expose only opaque catalog identifiers and ordinal codec
|
||||
epochs. The required `missioncore.observation-recorded-media/v2` manifest carries
|
||||
a strong `generation_sha256`, exact JS-safe aggregate `byte_length`, and finite
|
||||
epochs. The public compact
|
||||
`missioncore.observation-recorded-media/v3` manifest carries a strong
|
||||
`generation_sha256`, exact JS-safe aggregate `byte_length`, and finite
|
||||
`timeline_start_seconds` / `timeline_end_seconds` for every epoch. Epoch ends
|
||||
participate in the generation digest. Every epoch also declares its init byte
|
||||
length/digest and a complete contiguous segment list with sequence, URL, byte
|
||||
length and digest. The launch source repeats the same aggregate `byte_length`
|
||||
and uses exactly `max(epoch.timeline_end_seconds)` as its end; the spatial RRD
|
||||
end must never pad camera coverage. The browser cross-checks launch, manifest and
|
||||
component lengths. The current browser laboratory policy accepts at most 16
|
||||
camera sources, 128 MiB per source and 512 MiB across the session. This launch
|
||||
preflight finishes before the first camera manifest/init/segment GET; admitted
|
||||
cameras are then downloaded and decode-probed one at a time. Verified raw
|
||||
buffers remain immutable so route changes and player remounts cannot append
|
||||
emptied data. This bounded in-memory strategy is deliberate for the laboratory
|
||||
milestone; an OPFS-backed sealed-generation cache is the next scaling step for
|
||||
larger rigs and must preserve the same launch/manifest/hash admission contract.
|
||||
participate in the generation digest. Each epoch declares one generation-bound
|
||||
`stream_url`, media type and aggregate byte length; thousands of internal
|
||||
segment rows never enter browser memory. The launch source repeats the same
|
||||
aggregate `byte_length` and uses exactly `max(epoch.timeline_end_seconds)` as
|
||||
its end; the spatial RRD end must never pad camera coverage. The browser
|
||||
cross-checks launch and manifest identity, but applies no duration, per-source
|
||||
byte or aggregate-session byte admission ceiling.
|
||||
|
||||
The manifest response ETag is the exact generation, and the manifest GET itself
|
||||
requires that generation in `If-Match`. Init and media GETs likewise require the
|
||||
descriptor's exact SHA `If-Match`, then open through a confined directory file
|
||||
descriptor with no symlink following, verify the declared SHA-256 and serve
|
||||
exact-length bytes with immutable private `no-transform` caching and byte-range
|
||||
support. Physical source ids, RTSP addresses and storage paths never cross the
|
||||
API boundary.
|
||||
The `<video>` element reads the immutable virtual fMP4 through native HTTP Range
|
||||
requests. The server maps each requested interval onto init/segment files,
|
||||
opens them through confined descriptors with no symlink following and verifies
|
||||
the digest of each touched component. It never assembles the full video in
|
||||
backend or JavaScript memory. Responses carry exact `Content-Length` /
|
||||
`Content-Range`, a generation-and-epoch ETag, `Accept-Ranges: bytes` and private
|
||||
immutable `no-transform` caching. The manifest ETag is the exact generation and
|
||||
its GET requires the matching `If-Match`; the stream URL binds that same
|
||||
generation in its query. The older init/segment routes remain internal
|
||||
compatibility surfaces. Physical source ids, RTSP addresses and storage paths
|
||||
never cross the API boundary.
|
||||
|
||||
## Current limit
|
||||
## Current synchronization boundary
|
||||
|
||||
The spatial RRD, trajectory, archived device-metric series and archived fMP4
|
||||
cameras use the same operator scrubber now. Camera epochs are aligned to
|
||||
zero-based `session_time` from the shared host-arrival monotonic clock and
|
||||
rendered through MSE. The metric tab carries device-reported route distance,
|
||||
rendered through the browser's native fMP4/Range pipeline. The metric tab carries
|
||||
device-reported route distance,
|
||||
speed and scan time at their `ModelingReport` receive times. The client selects
|
||||
a camera epoch only inside its declared inclusive interval and verifies that the
|
||||
decoded MSE seekable duration covers that interval. This remains best-effort
|
||||
decoded native-media seekable duration covers that interval. This remains best-effort
|
||||
correlation: codec PTS, K1 sensor exposure time and LiDAR firing time are not
|
||||
proven to share a device clock. A codec epoch whose init segment does not expose
|
||||
a browser-supported codec or whose timing cannot be proven remains retained
|
||||
|
|
|
|||
|
|
@ -61,6 +61,306 @@ The result manifest binds the detection artifact's length and SHA-256. A repeat
|
|||
with the same identity validates the existing directory and returns it without
|
||||
calling Triton.
|
||||
|
||||
## Full-epoch panoptic result v2
|
||||
|
||||
`missioncore.recorded-perception-result/v2` is the complete recorded-camera
|
||||
profile. It is separate from the YOLOX detector proof and processes every
|
||||
admitted frame without sampling. Its immutable identity binds the complete
|
||||
compute job, factory-calibration generation and camera slot, exact model
|
||||
revisions/weight files, thresholds, alpha values, runner SHA-256 and publication
|
||||
encoder.
|
||||
|
||||
The current research configuration produces:
|
||||
|
||||
- TorchVision Mask R-CNN ResNet50-FPN v2 instance masks and COCO labels;
|
||||
- Microsoft BEiT ADE20K-150 semantic masks at the original 800x600 frame size;
|
||||
- a seekable H.264 MP4 with the two overlays combined for operator playback;
|
||||
- lossless instance and semantic mask PNGs for later calibrated fusion;
|
||||
- ordered per-frame JSONL and one-second GPU telemetry;
|
||||
- a machine-readable run report with input/config/model identities, decode,
|
||||
inference, encode and end-to-end timing, latency percentiles, throughput,
|
||||
CUDA peak allocation/reservation, process peak RSS, system load and GPU
|
||||
utilization/VRAM/temperature/power samples.
|
||||
|
||||
The worker runs with `--network none` and cached model weights. A preflight
|
||||
revalidates the complete transferred payload, CUDA execution and both cached
|
||||
model generations before decoding a long epoch. The run then repeats payload
|
||||
validation inside the inference container, requires exactly the declared frame
|
||||
count and a strictly increasing session-time row for every frame, and publishes
|
||||
only by atomic rename after every output digest is sealed.
|
||||
|
||||
There is no recorded-duration, frame-count or aggregate-video-byte admission
|
||||
ceiling in this profile. Resource use therefore scales with the real input and
|
||||
is reported, not hidden behind an arbitrary eight-minute laboratory limit.
|
||||
|
||||
## Native panoptic playback
|
||||
|
||||
Full raster masks are not copied into the RRD. That would turn a long video into
|
||||
a multi-gigabyte browser-memory object. Instead Mission Core validates the v2
|
||||
result and exposes its MP4 through the same generation-bound, seekable HTTP
|
||||
Range contract as a recorded camera. Replay advertises an additional opaque
|
||||
source such as `recorded.perception.right`; the Control Station opens it in a
|
||||
native video window on the shared `session_time` timeline. A one-, three- or
|
||||
ten-hour video remains disk/range streamed and does not have to fit in RAM.
|
||||
|
||||
The lossless masks remain private derived evidence. A host-side calibrated
|
||||
fusion step samples them at K1 KB4 LiDAR projections and publishes compact
|
||||
semantic `Points3D`, support-gated `Boxes3D` and diagnostic distances as a
|
||||
separate replaceable generation. Missing or rejected v2/fusion results never
|
||||
replace or invalidate the base raw point-cloud recording.
|
||||
|
||||
## RAVNOVES00 qualification · 2026-07-20
|
||||
|
||||
The first full recorded run admitted all 4,489 frames from
|
||||
`sensor.camera.right` without sampling, failures or skips. The sealed input was
|
||||
363,235,615 bytes over `35.421857292–484.144857292` session seconds. Its
|
||||
immutable result is
|
||||
`result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02`.
|
||||
|
||||
Measured RTX 4090 worker results:
|
||||
|
||||
- inference: 2,674.722 s and 1.678 frames/s;
|
||||
- end to end: 2,816.349 s and 1.594 frames/s;
|
||||
- instance latency: 89.826 ms p50, 121.344 ms p95, 915.325 ms max;
|
||||
- semantic latency: 249.678 ms p50, 284.826 ms p95, 406.594 ms max;
|
||||
- GPU utilization: 67% p50, 82% p95, 90% max over 2,675 one-second samples;
|
||||
- process CUDA peak: 2,230.8 MiB allocated and 2,872 MiB reserved;
|
||||
- total GPU memory observed, including the worker's shared resident services:
|
||||
13,373 MiB p50 and 13,388 MiB max;
|
||||
- GPU power/temperature: 182.46 W p50, 190.10 W p95 and 49 C p50, 54 C max;
|
||||
- process peak RSS: 2,375.9 MiB;
|
||||
- publication: 31.229 s decode, 3.397 s NVENC, 81,109,627-byte H.264 MP4,
|
||||
60,326,719-byte lossless mask archive.
|
||||
|
||||
The factory-calibrated full fusion generation
|
||||
`fusion-0b1be23128ebd3d230562cffd96491169e99f0e839e56b812661c974e4fdc00b`
|
||||
matched LiDAR and pose within the admitted 250 ms host-arrival window for 4,323
|
||||
frames and declared 166 frames `depth-unavailable`. It produced 6,124,145
|
||||
semantic points and 13,496 support-gated diagnostic boxes in 77.708 s. The
|
||||
compact fusion payload is 43 MiB.
|
||||
|
||||
Native browser QA opened `RAVNOVES00`, played the raw and panoptic 800x600
|
||||
videos together at ready-state 4, and measured approximately 12 ms between
|
||||
their media clocks. The Rerun scene showed the synchronized semantic points and
|
||||
distance-labeled diagnostic boxes. The current baseline is deliberately not an
|
||||
accuracy or safety acceptance: generic perspective-trained models produce
|
||||
large fisheye false positives in 916 frames, and timing/distance have not been
|
||||
ground-truthed. The next A/B should compare an admitted undistort/ROI transform
|
||||
before inference rather than silently hiding these observations.
|
||||
|
||||
## E1 valid-FOV preprocessing qualification · 2026-07-20
|
||||
|
||||
The first post-baseline A/B uses two immutable inputs derived from the same
|
||||
RAVNOVES00 job and factory calibration:
|
||||
|
||||
- valid-FOV generation
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`;
|
||||
- qualification-slice generation
|
||||
`qualification-slice-2394070b4f3e38f1b8c483e878fcc11fd3c29f751f3d4cd7e3a2553304c5c142`.
|
||||
|
||||
The mask is not estimated from each image. It is bound to the exact calibration
|
||||
SHA, `sensor.camera.right`, `camera_1`, the admitted 800x600 linear-resize
|
||||
profile and the KB4 principal point `(396.319, 301.496)`. A four-pixel inner
|
||||
margin produces a 293.504-pixel radius, 270,606 valid pixels (56.37625%) and an
|
||||
exclusive crop rectangle `[103, 8, 690, 595]`. Repeated preparation reuses the
|
||||
same content-addressed PNG and manifest.
|
||||
|
||||
The slice selects 256 exact frame indices uniformly across all 4,489 frames,
|
||||
including both endpoints. The same loaded FP32 Mask R-CNN and BEiT generations
|
||||
were interleaved per frame across three variants: unmodified baseline, fixed
|
||||
valid-FOV fill, and valid-FOV crop remapped into the original pixel coordinates.
|
||||
The sealed result is
|
||||
`qualification-result-98a2fee3d22979f3e18847719667cc76bdeacf25904c0fd1da4c5202253b3940`.
|
||||
|
||||
The run completed in 318.969 s and emitted 12 preview frames per variant. GPU
|
||||
telemetry recorded 319 one-second samples: utilization was 74% p50 and 82% p95,
|
||||
power was 199.34 W p50 and 206.96 W p95, and temperature was 50 C p50 and 55 C
|
||||
max. The qualification process peaked at 1,849.9 MiB CUDA allocated, 2,256 MiB
|
||||
reserved and 2,298.2 MiB RSS.
|
||||
|
||||
Measured mean model paths, excluding the frame decode shared by all variants:
|
||||
|
||||
| Variant | Mask R-CNN path | BEiT path | Combined |
|
||||
|---|---:|---:|---:|
|
||||
| baseline | 106.292 ms | 278.064 ms | 384.356 ms |
|
||||
| valid-FOV fill | 107.027 ms | 280.762 ms | 387.789 ms |
|
||||
| valid-FOV crop | 104.055 ms | 282.840 ms | 386.895 ms |
|
||||
|
||||
The crop reduced Mask R-CNN forward time by 6.38%, but BEiT still receives its
|
||||
fixed 640x640 tensor and became 1.18% slower. With mask/crop preprocessing
|
||||
included, neither variant improved the combined path; fill was 0.89% slower and
|
||||
crop was 0.66% slower than baseline. A binary mask improves admission quality,
|
||||
but multiplying an unchanged tensor by it does not remove dense neural FLOPs.
|
||||
|
||||
The quality proxies are useful but are not ground truth. Baseline produced 58
|
||||
instance masks and 58 boxes larger than half the admitted comparison area; both
|
||||
masked variants produced zero. The fraction of raw predicted instance-mask
|
||||
pixels outside the canonical FOV fell from 41.263% to 0.077% for fill and 0.911%
|
||||
for crop before the final output clamp. Inside the valid circle, mean BEiT
|
||||
disagreement with baseline was 8.713% for fill and 11.547% for crop. This is a
|
||||
measure of change, not accuracy.
|
||||
|
||||
E1 therefore accepts the immutable valid-FOV artifact and the 256-frame gate.
|
||||
Fixed fill is the conservative next accuracy baseline because it preserves the
|
||||
800x600 geometry, removes the exterior lens region and changes the semantic
|
||||
result less than crop. Crop remains an experimental model-specific option, not
|
||||
a general speed optimization. The next run needs human labels/ground truth and
|
||||
must compare native KB4 input, calibrated virtual views and fisheye-trained
|
||||
models before promoting any preprocessing profile to a full-epoch result.
|
||||
|
||||
## E2 evaluation pack and annotation gate · 2026-07-20
|
||||
|
||||
LAB E2 starts from the same immutable RAVNOVES00 compute job, E1 qualification
|
||||
slice and factory-calibrated valid-FOV generation. All eight contact sheets,
|
||||
covering the 256 uniformly distributed E1 candidates, were reviewed before
|
||||
selection. The sealed evaluation generation is
|
||||
`evaluation-pack-7a983bba75d46c7c260252cb2d461e1384dcb92cda9e164397e841e6ebb37789`.
|
||||
|
||||
The pack contains 64 exact 800x600 images: 48 reviewed full-epoch anchors from
|
||||
the E1 slice and four four-frame consecutive clips for temporal measurements.
|
||||
The clips cover a person with a stroller, a close moving car, vehicle
|
||||
occlusion/relative motion and a near building/terrace scene with a partially
|
||||
visible carried laptop. The anchors retain the recording's road, sidewalk,
|
||||
ground, grass, woody vegetation, buildings, sky, people, cars, trucks, lens
|
||||
boundary and hard-negative diversity.
|
||||
|
||||
Every image is stored both as the raw decoded RGB frame and as the accepted E1
|
||||
fixed-valid-FOV-fill input. The identity binds the job/input SHA, source, codec
|
||||
epoch, selected segment SHA, decoded session timestamp, E1 qualification,
|
||||
valid-FOV generation, calibration SHA, camera slot, FFmpeg 7.1.1 generation and
|
||||
the raw/fill RGB pixel hashes. It also binds the reviewed selection-document
|
||||
SHA and both producer-code hashes. The pack has 130 hashed payload artifacts
|
||||
plus its manifest and occupies approximately 67 MiB locally.
|
||||
|
||||
One earlier local preparation generation, `evaluation-pack-b6d9215a…`, was not
|
||||
promoted because its identity omitted the producer-code and selection-document
|
||||
hashes. It remains a superseded diagnostic artifact and is not an accepted E2
|
||||
input.
|
||||
|
||||
The immutable pack is deliberately `unannotated`. Its annotation contract
|
||||
defines 15 robotics-oriented thing/stuff classes, label 0 for the excluded lens
|
||||
exterior, label 255 for genuinely unresolved pixels, two-pass human review and
|
||||
required semantic, instance, safety-proxy and temporal metrics. The empty
|
||||
annotation template must be copied to a review workspace; it must never be
|
||||
edited inside the sealed pack. Model-generated prelabels may accelerate review
|
||||
but are not accepted as ground truth without a human pass.
|
||||
|
||||
No AP, mIoU or model-ranking claim is attached to E2 yet. The next gate is to
|
||||
complete and seal the reviewed annotations. Only then may candidate models be
|
||||
ranked on this pack; a full 4,489-frame run remains prohibited until one
|
||||
configuration passes both the accuracy and throughput gates.
|
||||
|
||||
The first model-assisted draft is sealed separately as
|
||||
`evaluation-prelabels-4ba26bbf6eb8a49631f5caf984267e0445958540aeda2b5b0d82ca6440835cf1`.
|
||||
It reuses the exact E0 Mask R-CNN and BEiT weights and maps their COCO/ADE
|
||||
classes into the E2 taxonomy. It is explicitly marked
|
||||
`unreviewed-model-draft`; it never mutates the evaluation pack or annotation
|
||||
template.
|
||||
|
||||
The isolated RTX 4090 run processed 64/64 frames in 31.551 s. Mean forward time
|
||||
was 57.539 ms for Mask R-CNN, 215.164 ms for BEiT and 272.703 ms combined. The
|
||||
process peaked at 1,842.8 MiB CUDA allocated, 2,768 MiB reserved and 2,239.9 MiB
|
||||
RSS. Across 32 one-second samples, GPU utilization was 51% p50 / 71% p95, power
|
||||
167.73 W p50 / 186.89 W p95 and temperature 42 C p50 / 47 C max. Shared Triton,
|
||||
Frigate and Ollama services remained running and healthy.
|
||||
|
||||
The draft emitted 775 mapped instances: 640 car, 58 static obstacle, 45 person,
|
||||
25 heavy vehicle, five bicycle and one each motorcycle/animal. Sixteen previews
|
||||
were reviewed. They confirm that the fixed-FOV exterior stays clean and that
|
||||
the draft is useful for annotation assistance, but also expose the expected E0
|
||||
domain errors: duplicated/distant car boxes, unstable small instances, coarse
|
||||
fisheye boundaries and excessive static-obstacle proposals on planters. These
|
||||
counts are workload indicators for review, not precision or recall.
|
||||
|
||||
The first prelabel attempt stopped before model loading because the container
|
||||
mountpoint `/evaluation-pack` was incorrectly required to equal the
|
||||
content-addressed generation basename. The path-name check was removed while
|
||||
all manifest, artifact and identity hashes remained mandatory. No failed result
|
||||
was published; the second attempt completed and 147 payload artifacts were
|
||||
reverified locally with zero digest/length mismatches.
|
||||
|
||||
The review handoff is sealed separately as
|
||||
`annotation-workspace-9a950d1c37d56dc12cc285b13c5addd7795285879cbcb1fbb2d5811c3c69821a`.
|
||||
It contains a deterministic 64-image upload, a 775-instance COCO RLE draft, a
|
||||
dense CVAT Segmentation Mask archive, an exact frame/timestamp map, the fixed
|
||||
valid-FOV mask, the 15-class label specification and an unreviewed two-pass
|
||||
checklist. The three ZIP archives passed both the workspace validator and
|
||||
independent ZIP integrity checks. The 11 payload artifacts occupy 31,861,798
|
||||
bytes. This workspace remains `ground_truth=false`; the two synchronized CVAT
|
||||
tasks must be reviewed and their accepted exports sealed as a separate
|
||||
generation before any AP or mIoU claim is allowed.
|
||||
|
||||
The real prelabel generation also exposed one identity-serialization defect in
|
||||
its producer: `target_categories` used integer dictionary keys while hashing,
|
||||
but JSON reloads them as strings and changes their sorted order. The stored
|
||||
artifact files and all 147 recorded payload hashes are unchanged. The workspace
|
||||
validator admits only this exact reversible legacy representation and binds the
|
||||
serialized `result.json` SHA separately. The worker producer now emits string
|
||||
keys before hashing, so subsequent prelabel identities are stable across a JSON
|
||||
round trip. The existing result was neither rewritten nor renamed.
|
||||
|
||||
Prepare or reproduce the review inputs with:
|
||||
|
||||
```console
|
||||
.venv/bin/python experiments/perception/prepare_e2_evaluation_pack.py candidates \
|
||||
--job-root .runtime/compute-jobs/<job-id> \
|
||||
--qualification-root .runtime/compute-experiments/e1/qualification-slices/<generation> \
|
||||
--output-root .runtime/compute-experiments/e2/<candidate-review>
|
||||
|
||||
.venv/bin/python experiments/perception/prepare_e2_evaluation_pack.py seal \
|
||||
--job-root .runtime/compute-jobs/<job-id> \
|
||||
--qualification-root .runtime/compute-experiments/e1/qualification-slices/<generation> \
|
||||
--valid-fov-root .runtime/compute-experiments/e1/valid-fov/<generation> \
|
||||
--selection .runtime/compute-experiments/e2/selection-e2.json \
|
||||
--output-root .runtime/compute-experiments/e2/evaluation-packs
|
||||
|
||||
.venv/bin/python experiments/perception/prepare_e2_annotation_workspace.py prepare \
|
||||
--evaluation-pack .runtime/compute-experiments/e2/evaluation-packs/<generation> \
|
||||
--prelabels .runtime/compute-experiments/e2/prelabels/<generation> \
|
||||
--valid-fov-root .runtime/compute-experiments/e1/valid-fov/<generation> \
|
||||
--output-root .runtime/compute-experiments/e2/annotation-workspaces
|
||||
```
|
||||
|
||||
## E4 full-session semantic playback · 2026-07-21
|
||||
|
||||
LAB E4 promotes the plain EoMT valid-FOV control from LAB E3 into the first
|
||||
complete saved-session semantic playback. It consumed all 4,489 frames of
|
||||
RAVNOVES00 (`20260720T065719Z_viewer_live`) from
|
||||
`sensor.camera.right`, using factory calibration slot `camera_1`, FP16
|
||||
autocast, batch size one and no sampling. CLAHE, five-view rectification and the
|
||||
instance branch were deliberately disabled.
|
||||
|
||||
The immutable published result is
|
||||
`result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30`.
|
||||
It contains an 800x600 H.264 semantic-overlay video, 4,489 semantic masks,
|
||||
4,489 timestamp rows, one-second GPU telemetry and a run report. FFprobe and the
|
||||
recorded-perception validator independently confirmed the exact frame count,
|
||||
448.723-second timeline, artifact hashes and input/job/calibration binding.
|
||||
|
||||
The inference loop ran for 1,447.565 seconds at 3.101 FPS. Full extraction,
|
||||
inference, publication, hashing and validation took 1,668.259 seconds at 2.691
|
||||
FPS. GPU utilization was 73.05% mean / 89% p95, E4 process CUDA allocation
|
||||
peaked at 2,099.8 MiB, process RSS at 2,009.3 MiB, power at 253.23 W and
|
||||
temperature at 58 C. The exact configuration is about 3.72 times slower than
|
||||
the source recording rate and is therefore an offline baseline, not a live
|
||||
configuration.
|
||||
|
||||
All task-controlled worker paths remained under `D:\NDC_MISSIONCORE`. The
|
||||
orchestrator enforced a 360 GiB free-space floor and a 17.195 GiB conservative
|
||||
working-set reserve. Final free space after exact task-temporary cleanup was
|
||||
379.395 GiB; C: was not used or mounted by the task.
|
||||
|
||||
Mission Core exposes the result in **Сохранённые сессии → RAVNOVES00 →
|
||||
Источники данных сцены → Сегментация · камера right**. Browser acceptance
|
||||
confirmed the exact result source, 800x600 dimensions, full duration, no media
|
||||
error and advancing playback time. The detailed configuration, timing tables,
|
||||
artifact hashes, disk checkpoints, limitations and next gates are recorded in
|
||||
`experiments/perception/LAB_E4_REPORT_2026-07-21.md` and Ops card MISSIONCOR-18.
|
||||
|
||||
E4 remains `ground_truth=false` and semantic-only. It makes no claim about live
|
||||
latency, instances, tracking, 3D cuboids, LiDAR association, distance accuracy,
|
||||
point-cloud labels or safety fitness.
|
||||
|
||||
## Recorded Rerun projection
|
||||
|
||||
Mission Core discovers only results whose validated job names the opened
|
||||
|
|
@ -105,3 +405,84 @@ accepted for obstacle avoidance, free-space estimation or safety decisions.
|
|||
tests.
|
||||
4. Introduce tracking, segmentation/free-space, calibration and point-cloud
|
||||
models as separate versioned pipelines.
|
||||
|
||||
## E5 recorded instance tracking qualification · 2026-07-21
|
||||
|
||||
LAB E5 establishes the first measured temporal object-identity baseline on a
|
||||
preselected 601-frame, 60.069-second RAVNOVES00 interval. It uses the official
|
||||
Apache-2.0 YOLOX-S ONNX release through the existing Triton service, the
|
||||
immutable `camera_1`-bound valid-FOV mask, and a ByteTrack-style two-stage IoU
|
||||
tracker. The accepted profile, runner, model and configuration are pinned by
|
||||
SHA-256; output remains `ground_truth=false` and qualification-only.
|
||||
|
||||
The immutable result is
|
||||
`e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6`.
|
||||
It contains an 800x600 H.264 ID-overlay video, exactly 601 timestamped detection
|
||||
and track rows, one-second GPU telemetry, a contact sheet and the complete run
|
||||
report. The result validator rehashed all artifacts and verified the exact job,
|
||||
input, session, source, clip and timeline binding. FFprobe independently
|
||||
confirmed 601 declared/read frames and 60.068948 seconds.
|
||||
|
||||
The frame loop ran for 88.093 seconds at 6.822 FPS; the complete worker run took
|
||||
166.336 seconds at 3.613 FPS. Mean per-frame time was 12.359 ms in Triton,
|
||||
0.247 ms in tracking, 30.132 ms decoding and 87.363 ms writing overlays. The
|
||||
detector and tracker are therefore not the main live-rate bottleneck; artifact
|
||||
I/O must be decoupled before a live path is admitted. GPU utilization was
|
||||
51.65% mean / 56% max, power 144.78 W mean / 148.40 W max, temperature 43 C max
|
||||
and process RSS 126.199 MiB.
|
||||
|
||||
The run admitted 4,049 detections and emitted 3,320 observations across 167
|
||||
confirmed IDs. Useful clear-view persistence is proven, including vehicle
|
||||
tracks lasting 100–212 frames. The result also exposes real limitations: an ID
|
||||
fragments during the close woman/stroller occlusion, the stroller can be called
|
||||
`motorcycle`, and parked vehicles can flicker between `car` and `truck`. There
|
||||
is no identity ground truth, so IDF1, HOTA, MOTA and true ID-switch counts are
|
||||
not claimed.
|
||||
|
||||
All task-controlled worker paths remained under `D:\NDC_MISSIONCORE`; the
|
||||
orchestrator enforced the 360 GiB floor and a 4.402 GiB working-set reserve.
|
||||
Free space after exact temporary cleanup was 380.595 GiB. The YOLOX model was
|
||||
loaded only for the run and restored to its prior unloaded state. Detailed
|
||||
provenance, pilot history, thresholds, timing tables, artifacts, hashes,
|
||||
quality findings and the LAB E6 gate are in
|
||||
`experiments/perception/LAB_E5_REPORT_2026-07-21.md` and Ops card
|
||||
MISSIONCOR-19.
|
||||
|
||||
The next bounded gate is calibrated 2D-track/LiDAR association on the same
|
||||
601-frame interval: robust point support, metric range, coarse 3D cuboids and a
|
||||
qualification-only Rerun recording. Full-session and live promotion remain
|
||||
blocked on measured identity quality and a bounded queue/drop design.
|
||||
|
||||
## E6 factory-calibrated tracked LiDAR fusion · 2026-07-21
|
||||
|
||||
LAB E6 closes the recorded 2D-track/LiDAR association gate on the exact E5
|
||||
601-frame interval. It reuses the immutable E4 semantic masks, E5 track IDs,
|
||||
raw K1 point/pose capture and the XGRIDS `camera_1` factory KB4 calibration.
|
||||
Fused observations must satisfy 100 ms camera/point and pose/point gates.
|
||||
Nearest-depth buffering, semantic support, depth splitting, 3D connected
|
||||
components, robust range history and plausible-size gates prevent unsupported
|
||||
image rectangles from becoming fabricated 3D boxes.
|
||||
|
||||
The immutable result is
|
||||
`e6-fusion-b4e4226674a66f6196c033785eb307c255a7bdd8493ef9809dfb1d6e5bd68eaa`.
|
||||
It processed 601/601 frames, fused 526, emitted 918 point-supported oriented
|
||||
cuboids across 56 track IDs and failed closed for depth on 75 frames outside
|
||||
the strict timing gate. LiDAR/camera absolute delta was 29.389 ms mean,
|
||||
70.794 ms p95 and 96.107 ms maximum. Accepted range spans 1.239–37.749 m with a
|
||||
10.189 m median.
|
||||
|
||||
The single-process local geometry/artifact path took 14.774 seconds, peaked at
|
||||
240.469 MiB RSS and is faster than the source rate. This is not an end-to-end
|
||||
live result because E4 and E5 were precomputed. The external worker and both
|
||||
worker disks were untouched. The output includes an 800x600 H.264 overlay,
|
||||
timestamped JSONL/NPZ data, a contact sheet and a standalone 60 MiB Rerun
|
||||
recording with camera, map-frame cloud, support points and translucent
|
||||
`Boxes3D`. Visual QA confirmed that these are real oriented Rerun primitives;
|
||||
they remain visible-surface envelopes, not ground-truthed complete object
|
||||
volumes.
|
||||
|
||||
Detailed provenance, profile freeze, pilot failures, thresholds, timing,
|
||||
rejection counts, artifact hashes, limitations and the LAB E7 gate are in
|
||||
`experiments/perception/LAB_E6_REPORT_2026-07-21.md`. Ops synchronization is
|
||||
pending restoration of the direct `nodedc-ops-agent` tasker tools; the legacy
|
||||
Ops API was not used.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,453 @@
|
|||
# K1 calibrated perception roadmap
|
||||
|
||||
Status date: 2026-07-20.
|
||||
|
||||
## Outcome
|
||||
|
||||
Mission Core will use the K1 factory camera/LiDAR calibration instead of
|
||||
building a new calibration workflow for the assembled rig. The first product
|
||||
vertical is recorded, deterministic and fail-closed:
|
||||
|
||||
```text
|
||||
canonical camera epoch + canonical LiDAR epoch
|
||||
+ device-bound factory calibration snapshot
|
||||
+ versioned segmentation/detection result
|
||||
-> pixel masks
|
||||
-> labeled LiDAR points and object distances
|
||||
-> evidence-backed 3D boxes
|
||||
-> optional derived Rerun layer
|
||||
```
|
||||
|
||||
Only after this vertical passes on `RAVNOVES00` does the same contract move to
|
||||
bounded live processing. Rerun remains the viewer and recording projection;
|
||||
inference, calibration validation and fusion remain Mission Core concerns.
|
||||
|
||||
## Facts already established
|
||||
|
||||
### Camera identity is already canonical
|
||||
|
||||
The K1 plugin does not need to infer left versus right from image content. It
|
||||
owns the exact RTSP selection:
|
||||
|
||||
| Mission Core source | K1 RTSP endpoint |
|
||||
|---|---|
|
||||
| `sensor.camera.left` | `/live/chn_left_main` |
|
||||
| `sensor.camera.right` | `/live/chn_right_main` |
|
||||
|
||||
The selected source ID is retained by the camera gateway, the durable archive
|
||||
summary and `missioncore.compute-job/v1`. A source switch creates another codec
|
||||
epoch rather than silently changing the meaning of an existing epoch.
|
||||
|
||||
The accepted session `20260720T065719Z_viewer_live`, displayed as
|
||||
`RAVNOVES00`, contains one recorded video source:
|
||||
|
||||
```text
|
||||
source_id: sensor.camera.right
|
||||
codec_epoch: 1
|
||||
decoded video: H.264, 800x600
|
||||
```
|
||||
|
||||
Consequently camera selection for perception must be a lookup from the job's
|
||||
canonical `source_id`, never a UI default and never a computer-vision guess.
|
||||
|
||||
### Factory calibration exists and is used by K1
|
||||
|
||||
Read-only firmware analysis of K1 firmware `3.0.2` established:
|
||||
|
||||
- factory camera parameters are read from
|
||||
`/mnt/system/factory-data/config/camera.yaml`;
|
||||
- camera/LiDAR extrinsics are read from
|
||||
`/mnt/system/factory-data/config/extrinsic_camera_lidar.yaml`;
|
||||
- the internal frontend consumes camera intrinsics, distortion and
|
||||
`T_rgb_lidar`;
|
||||
- `libxcolor` consumes the calibration and produces the colorized point cloud;
|
||||
- K1 enables PTP, synchronized left/right main cameras and real-time xcolor;
|
||||
- the main camera calibration model is KB4 fisheye at `4000x3000`;
|
||||
- the exposed file protocol has a distinct read action, `CacFileRead = 5`, and
|
||||
a distinct write action, `CacFileWrite = 6`.
|
||||
|
||||
The OTA image contains model defaults. The exact values for one physical K1
|
||||
live in that unit's `factory-data`; Mission Core must snapshot those exact
|
||||
files before it claims calibrated fusion.
|
||||
|
||||
### Geometric calibration is now normalized; timing remains separate
|
||||
|
||||
The physical A4/FW 3.0.2 unit was read on 2026-07-20. Firmware declarations,
|
||||
the xcolor loader and the unit's exact native resolutions jointly establish:
|
||||
|
||||
| Mission Core source | firmware camera | factory slot | native | admitted |
|
||||
|---|---|---|---:|---:|
|
||||
| `sensor.camera.left` | `camera_left_main` | `camera_0` | 4000x3000 | 800x600 |
|
||||
| `sensor.camera.right` | `camera_right_main` | `camera_1` | 4000x3000 | 800x600 |
|
||||
|
||||
`camera_2` and `camera_3` are the 1280x800 left/right secondary pair and are
|
||||
not silently substituted for a main RTSP source. The main H.264 substream is a
|
||||
firmware-configured 800x600 linear resize with scale `(0.2, 0.2)` and no crop or
|
||||
warp stage in the admitted profile. KB4 distortion is unchanged; `fx`, `fy`,
|
||||
`cx` and `cy` are scaled on their respective axes.
|
||||
|
||||
The normalized matrix notation is `T_destination_from_source`, row-major
|
||||
homogeneous 4x4, with translation in metres. The file-level `transform` is
|
||||
`T_camera_0_from_lidar`. A serialized `camera_N.camera_pose` is
|
||||
`T_camera_0_from_camera_N`; the same inverse-and-compose operation used by
|
||||
xcolor produces:
|
||||
|
||||
```text
|
||||
T_camera_N_from_lidar = inverse(T_camera_0_from_camera_N)
|
||||
* T_camera_0_from_lidar
|
||||
```
|
||||
|
||||
The remaining uncertainty is temporal: exact camera exposure time versus the
|
||||
host-arrival timestamps currently retained by the external archive. This is a
|
||||
separate temporal-calibration gate and is not hidden inside accepted geometry.
|
||||
|
||||
## Calibration product boundary
|
||||
|
||||
### What the UI should expose
|
||||
|
||||
The normal device panel should show a small calibration status card:
|
||||
|
||||
- `Ready`, `Missing`, `Mismatch` or `Unverified`;
|
||||
- device model and firmware profile;
|
||||
- captured-at time and immutable snapshot digest;
|
||||
- canonical left/right source to calibration-slot mapping;
|
||||
- native calibrated resolution and admitted stream resolution;
|
||||
- geometry verification status;
|
||||
- timing status separately from geometry status.
|
||||
|
||||
It may expose one explicit **Read snapshot from connected K1** operation. That
|
||||
operation is read-only at the product level and must show the exact two admitted
|
||||
files and the resulting digest.
|
||||
|
||||
### What the UI should not expose
|
||||
|
||||
Mission Core must not accept or upload a firmware archive merely to obtain
|
||||
calibration. A general firmware uploader would add OTA, integrity, rollback and
|
||||
device-bricking responsibilities without helping the perception path.
|
||||
|
||||
An advanced offline import is useful only for replay, recovery or a device that
|
||||
cannot currently be reached. It must accept a small Mission Core calibration
|
||||
bundle containing only the two admitted YAML documents plus identity metadata.
|
||||
It must not accept a firmware TAR, arbitrary filesystem path or device write.
|
||||
|
||||
### Read-only device snapshot rules
|
||||
|
||||
The existing plugin action `calibration.device-snapshot.read` becomes the only
|
||||
live entry point. Its implementation must:
|
||||
|
||||
1. require an already verified, owner-controlled K1 session;
|
||||
2. publish only the reviewed `CacFileRead` request;
|
||||
3. use an exact path allowlist for the two factory YAML files;
|
||||
4. reject redirects, relative paths, extra response paths and write commands;
|
||||
5. bound each response before YAML parsing;
|
||||
6. parse with a safe loader and an exact schema/finite-number validation;
|
||||
7. record original byte length and SHA-256 without rewriting the source bytes;
|
||||
8. bind the snapshot to device identity, model, firmware and compatibility
|
||||
profile;
|
||||
9. publish the complete snapshot atomically in private storage;
|
||||
10. leave `CacFileWrite` unavailable.
|
||||
|
||||
The snapshot contract must name transform direction and coordinate convention
|
||||
explicitly. A matrix called only `extrinsic` or `T` is not admissible at the
|
||||
Mission Core boundary.
|
||||
|
||||
### Implementation status — 2026-07-20
|
||||
|
||||
The first read-only boundary is now implemented:
|
||||
|
||||
- `protocol/calibration_file.py` encodes only command `5` and has no API for
|
||||
command `6`;
|
||||
- its request path is a closed two-value type at runtime: `camera.yaml` or
|
||||
`extrinsic_camera_lidar.yaml` at their exact factory-data locations;
|
||||
- response identity, session, authority, command, path, result code, UTF-8,
|
||||
duplicates and byte bounds fail closed;
|
||||
- `protocol/calibration_mqtt.py` performs one `DeviceInfo` read followed by the
|
||||
two sequential file reads on one clean MQTT connection, with no reconnect or
|
||||
automatic retry;
|
||||
- `calibration.device-snapshot.read` now invokes that physical read instead of
|
||||
returning the generic plugin state;
|
||||
- before that read, Bridge/Direct Connect refresh the selected K1's DHCP lease
|
||||
from the read-only BLE status characteristic; the IP is session state, while
|
||||
the correlated MQTT `DeviceInfo` is the identity barrier;
|
||||
- each successful read seals a new private `0700` directory with `0600` source
|
||||
files, a manifest and SHA-256 identities; no earlier snapshot is replaced;
|
||||
- `calibration_schema.py` rejects duplicate keys, anchors, aliases, explicit
|
||||
tags, unknown fields, non-finite values, non-K1 dimensions and invalid rigid
|
||||
transforms before a snapshot can become available;
|
||||
- the manifest contains explicit left/right bindings, the 800x600 image
|
||||
transform, KB4 parameters and `T_camera_N_from_lidar` matrices;
|
||||
- the physical normalized snapshot captured at `2026-07-20T10:54:32.613Z`
|
||||
has content identity
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`;
|
||||
- the complete Python suite passes with the boundary installed.
|
||||
|
||||
The P0 geometric-input subgate is accepted. The recorded Rerun diagnostic is
|
||||
also generated and opens in the native viewer. P0 as a whole still requires
|
||||
measured reprojection agreement across the image and a temporal-offset budget;
|
||||
parsing a valid matrix or visually plausible overlay is not a substitute for
|
||||
those measurements.
|
||||
|
||||
The first reproducible recorded diagnostic was generated on 2026-07-20 by the
|
||||
new `k1link analyze calibrated-overlay` path. Experiment
|
||||
`20260720T112515Z_k1_calibrated_overlay_2655c5c7c38e` is bound to the immutable
|
||||
RAVNOVES00 MQTT, camera and calibration generations. It produced four
|
||||
depth-coloured PNG overlays plus a 4,016,830-byte Rerun diagnostic with SHA-256
|
||||
`83bc5c1134563987c3be5734d6c069d07ad8a9e5b6806682169c89f4bd5354d9`.
|
||||
|
||||
The experiment established one previously implicit frame operation: current
|
||||
`lio_pcl` coordinates are in the K1 map frame, so projection uses
|
||||
`inverse(T_map_from_lidar)` before `T_camera_1_from_lidar` and KB4. The four
|
||||
selected camera frames projected 1,613/2,617, 983/1,813, 1,543/2,744 and
|
||||
1,730/1,880 source points respectively. Visual inspection shows coherent
|
||||
facade, vehicle, vegetation and ground structure across the fisheye image.
|
||||
This is diagnostic evidence, not a measured pixel-error acceptance: explicit
|
||||
static landmark correspondences and the temporal-offset sweep remain open.
|
||||
|
||||
## Recorded segmentation and calibrated fusion probe
|
||||
|
||||
A four-frame P1/P2/P3 probe now exists on the same immutable RAVNOVES00 camera
|
||||
anchors. The RTX worker received the raw camera pixels, not the LiDAR-coloured
|
||||
overlay, and the transferred PNG SHA-256 values were verified on both hosts.
|
||||
|
||||
The admitted research profiles are:
|
||||
|
||||
- TorchVision Mask R-CNN ResNet50-FPN v2 / COCO_V1 for instance masks, with
|
||||
weight SHA-256
|
||||
`73cbd0190fcbe3ba339921fbce2c3a0b6bb9126c9a133c85e43a2a8e060a109e`;
|
||||
- Microsoft BEiT base / ADE20K-150 for semantic masks, revision
|
||||
`a8b6f5ef4acb2ea55d882989deaa02d39401e2b2`, with weight SHA-256
|
||||
`e0747360d190bd7c0f53d2fe3b2ed560c304d3eefef94574af9c7e93aaf8e7a9`.
|
||||
|
||||
BEiT was published only after an exact checkpoint load with no missing,
|
||||
unexpected or mismatched keys. An earlier Intel DPT/ADE probe was rejected:
|
||||
its checkpoint left twelve fusion-layer parameters newly initialized. The
|
||||
strict repeat failed before publication, so that output is diagnostic only and
|
||||
cannot enter fusion.
|
||||
|
||||
The accepted segmentation generation is
|
||||
`1ef80a6df60830d8ac37f09db679696325adb65ef61094b666165f561b992a08`.
|
||||
On the three warm frames, sequential Mask R-CNN inference took 100–124 ms and
|
||||
BEiT took 244–312 ms. The combined 365–412 ms is only about 2.4–2.7 FPS before
|
||||
decode, transfer and fusion; this is not a live real-time acceptance.
|
||||
|
||||
Mission Core sampled both masks at factory-calibrated LiDAR projections and
|
||||
published fusion experiment
|
||||
`20260720T121000Z_k1_segmentation_fusion_d09020d0cb13`, generation
|
||||
`3d150012cf88ad477c602aa072d0b6c2675ca472204b61d9377eb8b83c128842`.
|
||||
Distance is Euclidean range from the current LiDAR origin. A diagnostic box is
|
||||
published only after the instance mask contains at least four points in one
|
||||
contiguous depth cluster; its current bounds are map-frame axis-aligned p05–p95
|
||||
bounds with a 12 m maximum span gate.
|
||||
|
||||
| camera session | projected points | diagnostic Boxes3D |
|
||||
|---:|---:|---:|
|
||||
| 95.471 s | 1,613 | 2 |
|
||||
| 215.408 s | 983 | 5 |
|
||||
| 335.424 s | 1,543 | 3 |
|
||||
| 455.450 s | 1,730 | 4 |
|
||||
|
||||
The span gate rejected a large false `car` mask even though it contained 854
|
||||
clustered points. Sparse objects remain 2D observations with an explicit
|
||||
insufficient-support status. The 11,582,442-byte Rerun RRD has SHA-256
|
||||
`1c190e6dc64b8c1e187c053cd950dabc4167d1d7fe3c8ec5e2499c0d5d3fa471`.
|
||||
Native headless viewer QA confirmed synchronized raw, semantic and fusion image
|
||||
views, semantic `Points3D`, and translucent `Boxes3D` with distance/support
|
||||
labels. Distances and boxes are not ground-truthed, tracked or safety accepted.
|
||||
|
||||
## Automatic camera/calibration binding
|
||||
|
||||
The binding is performed when a compute job is prepared:
|
||||
|
||||
```text
|
||||
camera archive summary.source_id
|
||||
-> compatibility-profile source mapping
|
||||
-> exact camera slot in device calibration snapshot
|
||||
-> image-transform profile for the admitted codec epoch
|
||||
-> calibration generation SHA-256 in the compute job
|
||||
```
|
||||
|
||||
Preparation fails closed when any link is absent or ambiguous. The worker must
|
||||
not choose a default camera. The result repeats the calibration generation and
|
||||
camera source; Mission Core revalidates both before accepting a derived layer.
|
||||
|
||||
The left/right mapping is now device-snapshot data backed by the exact FW 3.0.2
|
||||
profile. The compute-job binding is still pending; no worker may infer a slot
|
||||
from list order or use a default camera.
|
||||
|
||||
## Minimal recorded vertical
|
||||
|
||||
### Gate P0 — calibration snapshot and camera mapping
|
||||
|
||||
Deliverables:
|
||||
|
||||
- bounded protobuf codec for `CalibFileRequest` and `CalibFileResponse`;
|
||||
- whitelisted read-only snapshot action and tests;
|
||||
- immutable private snapshot with source digests;
|
||||
- verified mapping for both canonical camera source IDs;
|
||||
- validated 4000x3000-to-800x600 image transform;
|
||||
- a Rerun diagnostic layer showing projected LiDAR samples over the image.
|
||||
|
||||
Acceptance requires visible and measured agreement on several static regions
|
||||
across the image, not one hand-picked object at the center.
|
||||
|
||||
### Gate P1 — full-timeline 2D perception
|
||||
|
||||
Run the complete `RAVNOVES00` camera epoch, not one still image. Preserve every
|
||||
decoded frame's admitted session timestamp and produce two independent
|
||||
versioned outputs:
|
||||
|
||||
- instance masks for countable objects such as people and vehicles;
|
||||
- semantic classes for background/stuff such as ground, building and
|
||||
vegetation.
|
||||
|
||||
Together these form the requested panoptic view. The existing YOLOX-S result is
|
||||
a detector smoke test and remains useful as a baseline, but it cannot satisfy
|
||||
this gate. Model choice remains replaceable behind the result contract and is
|
||||
accepted only after measured accuracy, latency, VRAM and license review.
|
||||
|
||||
Operator playback uses a generation-bound H.264 panoptic MP4 beside the native
|
||||
Rerun canvas. Embedding every 800x600 `SegmentationImage` row in RRD would make
|
||||
multi-hour raster playback depend on browser memory. Lossless masks remain
|
||||
content-addressed derived evidence for fusion, while the MP4 is read through
|
||||
native HTTP Range and follows the same `session_time` controller as the source
|
||||
camera and point cloud. Small diagnostic RRDs may still use
|
||||
`SegmentationImage`, `AnnotationContext` and `Boxes2D`; that is not the scalable
|
||||
full-epoch transport.
|
||||
|
||||
### Gate P2 — 2D-to-3D fusion and distance
|
||||
|
||||
For each admitted camera frame:
|
||||
|
||||
1. select a temporally compatible LiDAR frame/window;
|
||||
2. transform LiDAR points into the selected camera frame;
|
||||
3. project with the K1 KB4 model and the validated stream transform;
|
||||
4. reject points behind the camera and resolve occlusion with a depth buffer;
|
||||
5. sample the panoptic mask at each visible projected point;
|
||||
6. retain labeled points in LiDAR coordinates;
|
||||
7. compute per-instance nearest robust distance, centroid and distance spread;
|
||||
8. cluster instance points and fit an oriented 3D box only when support is
|
||||
sufficient.
|
||||
|
||||
A 2D rectangle must never be blindly extruded into a fake 3D box. An object
|
||||
with insufficient or stale LiDAR support remains a 2D observation with an
|
||||
explicit `depth unavailable` reason.
|
||||
|
||||
Distance v1 should be displayed directly on each object label. Click-to-query
|
||||
is a later interaction enhancement, not a dependency of correct distance.
|
||||
|
||||
### Gate P3 — Rerun 3D presentation
|
||||
|
||||
The derived layer logs:
|
||||
|
||||
- semantic/instance masks on the camera image;
|
||||
- labeled points as `Points3D` using the same class ontology;
|
||||
- supported objects as oriented `Boxes3D`;
|
||||
- object ID, class, confidence, distance and support count;
|
||||
- calibration/result generation metadata and degraded-state reasons.
|
||||
|
||||
Rerun's native camera projection is pinhole-only. K1 uses KB4 fisheye, so
|
||||
Mission Core must perform the calibrated KB4 projection itself or log a proven
|
||||
undistorted image. It must not present an uncorrected pinhole frustum as exact
|
||||
K1 geometry.
|
||||
|
||||
### Gate P4 — recorded qualification
|
||||
|
||||
Qualify the entire 8–9 minute session with:
|
||||
|
||||
- no frame or segment ceiling;
|
||||
- deterministic repeat identity;
|
||||
- end-to-end throughput and wall time;
|
||||
- CPU, RAM, GPU, VRAM and disk telemetry;
|
||||
- calibration reprojection statistics;
|
||||
- temporal-offset sweep and moving-object error report;
|
||||
- explicit failure/degraded reasons;
|
||||
- immutable raw inputs and separately replaceable derived results.
|
||||
|
||||
## Bounded live vertical
|
||||
|
||||
Only after P0–P4 pass does live mode reuse the same model and fusion contracts.
|
||||
|
||||
The live path has two different policies:
|
||||
|
||||
- archival inputs are never dropped by the viewer or inference queue;
|
||||
- derived preview is latest-wins with a small bounded queue and explicit
|
||||
dropped/stale counters.
|
||||
|
||||
The first live target is stable 5–10 Hz derived perception, not an unsupported
|
||||
claim of matching every camera or LiDAR message. It must expose:
|
||||
|
||||
- capture, queue, decode, inference, fusion and render latency;
|
||||
- queue depth and dropped derived frames;
|
||||
- calibration generation in use;
|
||||
- worker disconnect/recovery state;
|
||||
- `healthy`, `degraded`, `stale` and `unavailable` states;
|
||||
- raw recording continuity when the worker is slow or absent.
|
||||
|
||||
The external RTX worker remains an executor. It receives bounded observations
|
||||
and calibration references; it gains no K1 command authority.
|
||||
|
||||
## Deferred intentionally
|
||||
|
||||
The following are not prerequisites for the first correct vertical:
|
||||
|
||||
- uploading full firmware through Mission Core;
|
||||
- calibrating the already factory-calibrated assembled K1 rig again;
|
||||
- native LiDAR-only neural segmentation;
|
||||
- multi-camera stitching;
|
||||
- multi-object tracking across long occlusions;
|
||||
- HD maps, ROS 2, vehicle control or safety decisions;
|
||||
- a universal model marketplace or scheduler.
|
||||
|
||||
Native point-cloud segmentation and tracking may be added later as independent
|
||||
pipelines and compared against the camera-fused labels. They must not block the
|
||||
first evidence-backed 3D boxes and distances.
|
||||
|
||||
## Immediate implementation order
|
||||
|
||||
1. Keep the accepted Windows reboot path healthy and reproducible.
|
||||
2. ~~Implement and offline-test the bounded calibration file protobuf codec.~~
|
||||
3. ~~Implement `calibration.device-snapshot.read` with the two-path allowlist.~~
|
||||
4. ~~Capture the physical K1 snapshot and prove left/right slot mapping.~~
|
||||
5. ~~Generate a reproducible calibrated LiDAR-over-video diagnostic on
|
||||
`RAVNOVES00`.~~ Record measured static-landmark reprojection error and the
|
||||
temporal-offset budget for the accepted 800x600 transform.
|
||||
6. Extend compute job/result contracts with camera source and calibration
|
||||
generation binding.
|
||||
7. ~~Prove instance and semantic masks on four physical frames.~~ Produce the
|
||||
complete recorded epoch with immutable result identity and exact-repeat
|
||||
reuse.
|
||||
8. ~~Prove mask-to-point fusion, diagnostic distances and support-gated 3D
|
||||
boxes on four frames.~~ Add ground truth, tracking and accepted oriented-box
|
||||
fitting.
|
||||
9. ~~Open the four-frame fused result in native Rerun.~~ Qualify the full
|
||||
session with resource and timing telemetry.
|
||||
10. ~~Qualify the bounded latest-wins scheduler and machine world-state contract
|
||||
with source-paced replay.~~ Move the same contract onto the real worker
|
||||
inference path, then connect the live K1.
|
||||
|
||||
## LAB E7 replay-real-time checkpoint · 2026-07-21
|
||||
|
||||
LAB E7 accepted the first source-paced downstream perception runtime on the
|
||||
same 601-frame E6 interval. The result
|
||||
`e7-live-replay-e339aaed75fff05ed893ae48770bd127cc0c764f3124fc4b6be94b5fd9f8389c`
|
||||
published 601/601 machine world states at 10.021845 Hz with zero drops, a
|
||||
capacity-two queue and 5.927 ms p95 scheduled-tick-to-publication latency. The
|
||||
526 fused frames were healthy; all 75 E6 sync-gated frames were explicitly
|
||||
degraded, with no stale or unavailable states.
|
||||
|
||||
The world state carries object identity, class, map and K1-LiDAR position,
|
||||
visible-surface 3D size, range, support, guarded diagnostic velocity and a
|
||||
72-sector diagnostic clearance observation. Vehicle-body coordinates remain
|
||||
unavailable until a real sensor-to-vehicle transform is supplied. Rerun is a
|
||||
subscriber showing point cloud, `Boxes3D`, latency, queue depth and health; it
|
||||
is not the perception authority.
|
||||
|
||||
An intentional 250 ms consumer-delay control processed 33/80 frames, discarded
|
||||
47 obsolete derived frames, never exceeded queue depth two and marked 32
|
||||
published states stale. Raw evidence remained outside the derived queue. This
|
||||
proves bounded overload behavior, not live inference. E4, E5 and E6 inputs were
|
||||
precomputed and the AI worker was untouched.
|
||||
|
||||
Full provenance, pilot corrections, profile hashes, latency tables, velocity
|
||||
guards, clearance limits, artifacts and the LAB E8 gate are recorded in
|
||||
`experiments/perception/LAB_E7_REPORT_2026-07-21.md`.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# ADR 0013: explicit K1 local connection matrix
|
||||
|
||||
- Status: accepted for implementation; physical acceptance is mode-specific
|
||||
- Status: amended 2026-07-20; Bridge is the product path, Quick Connect retained as a prepared-host laboratory path
|
||||
- Date: 2026-07-19
|
||||
- Extends: ADR 0004, ADR 0005 and ADR 0012
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ firmware `3.0.2` compatibility profile:
|
|||
| --- | --- | --- | --- | --- |
|
||||
| Bridge | `direct-lan` | one reviewed BLE provisioning write | none | physically accepted in Mission Core |
|
||||
| Direct Connect | `controller-hotspot` | the same reviewed BLE provisioning write | operator prepares the hotspot and route | implementation complete; physical run pending |
|
||||
| Quick Connect | `device-ap` | none | one CoreWLAN scan and association | LixelGO data plane observed; Mission Core host run pending |
|
||||
| Quick Connect | `device-ap` | one fixed reviewed 100-byte AP-enable write | preinstalled exact-firmware credential provider, bounded exact-SSID discovery and one association | physically accepted on one prepared Mac; not portable bootstrap |
|
||||
|
||||
Bridge remains the default. Every API request carries both `connection_mode`
|
||||
and its exact topology attestation; mismatched pairs are rejected before any
|
||||
|
|
@ -36,36 +36,121 @@ network action. Acquisition and camera admission must match the active mode.
|
|||
The fixed K1 AP address is admitted only after a successful Quick Connect host
|
||||
association.
|
||||
|
||||
Bridge and Direct Connect do not have a fixed product IP. The address reported
|
||||
by BLE characteristic `7f02` is a DHCP lease observation scoped to the current
|
||||
connection, never device identity and never a durable configuration value.
|
||||
Before a new application-control session, an implicit-host acquisition, or a
|
||||
factory-calibration read, Mission Core performs one read-only `7f02` read for
|
||||
the selected CoreBluetooth device and replaces the previous target. An address
|
||||
change creates a new `device_session_id` and invalidates calibration captured
|
||||
for the previous session. An active acquisition is never retargeted in place.
|
||||
The later correlated MQTT `DeviceInfo` response supplies model, firmware,
|
||||
serial and vendor identity; IP equality alone cannot identify a K1.
|
||||
|
||||
Product decision on 2026-07-20: Bridge/direct-LAN is the continuing route.
|
||||
Quick Connect remains visible and executable on an already prepared host, but
|
||||
is not a deployment dependency or portability claim.
|
||||
|
||||
Direct Connect does not introduce a new vendor payload. The operator first
|
||||
starts a hotspot on the controlling device and enters that hotspot's SSID and
|
||||
password. Mission Core performs the same single, physically reviewed BLE write
|
||||
used by Bridge and accepts only the non-AP private IPv4 returned by K1 status.
|
||||
|
||||
Quick Connect does not write GATT. A short-lived macOS Swift/CoreWLAN helper
|
||||
receives the operator-entered AP SSID and password as bounded JSON on stdin.
|
||||
The credential never appears in argv, environment, stdout, stderr, manifests or
|
||||
browser persistence. The helper performs at most one scan and one association;
|
||||
Mission Core does not retry. The implementation intentionally does not infer or
|
||||
read the K1 AP password from an undocumented BLE structure. Until that read has
|
||||
its own captured characteristic, bounded decoder and review, the operator must
|
||||
enter the AP credentials shown for the owner-controlled scanner.
|
||||
Quick Connect does not accept a credential from the browser/API. The first
|
||||
implementation incorrectly jumped from BLE discovery directly to host Wi-Fi
|
||||
association. A physical negative run showed that the selected K1 was still on
|
||||
the existing LAN and the expected AP was not visible. Re-review of the exact
|
||||
LixelGO branch recovered the missing state transition: after BLE connection it
|
||||
writes one 100-byte `7f01` frame, zero except for `0x01` at offset 99, and treats
|
||||
the callback as the AP-launch result before invoking the OS Wi-Fi connector.
|
||||
|
||||
No connection-verification button emits a probe. It validates only the admitted
|
||||
private address; the later canonical MQTT session supplies the real data-plane
|
||||
connection attempt. A failed or ambiguous network action remains terminal until
|
||||
the operator checks physical state and explicitly starts a new operation.
|
||||
Mission Core now performs that bounded AP-enable write at most once. The first
|
||||
physical run reached `WIFI_AP` at the reviewed AP address, and a targeted
|
||||
CoreWLAN scan found the SSID matching the selected BLE advertised name. It also
|
||||
disproved the earlier assumption that LixelGO carried one global Quick Connect
|
||||
profile: retained client analysis exposes `WiFiAP_SSID` and `WiFiAP_Password` as
|
||||
fields of each device record.
|
||||
|
||||
A subsequent run found that `7f02` may retain `WIFI_AP / 192.168.56.1` after the
|
||||
SSID stops beaconing. Static review then recovered the missing discriminator:
|
||||
LixelGO maps response byte 51 to its AP-ready flag and waits up to 15 seconds for
|
||||
that flag. Mission Core therefore mirrors LixelGO: every new operator Quick
|
||||
Connect action emits exactly one reviewed enable frame, including from a
|
||||
`WIFI_AP` baseline, and admits host discovery only after byte 51 becomes
|
||||
non-zero. This is not an automatic retry.
|
||||
|
||||
The same review proved that LixelGO does not close its BLE manager between the
|
||||
AP-ready callback and native Wi-Fi connect. A failed Mission Core run had done
|
||||
exactly that: byte 51 changed from zero to one, the Python BLE context exited,
|
||||
and the following cold Swift/CoreWLAN process missed the beacon. The corrected
|
||||
implementation holds the selected `BleakClient` open through bounded native
|
||||
SSID discovery and the single association call.
|
||||
|
||||
BLE discovery and the selected device action form one host session. A physical
|
||||
run proved that immediately rediscovering the same K1 by its CoreBluetooth UUID
|
||||
can fail even though the preceding scan exposed it. Mission Core retains the
|
||||
non-serializable `BLEDevice` handle process-locally and uses that exact handle
|
||||
for the next selected network action; it never exposes the handle through API
|
||||
state or treats the macOS UUID as durable device identity.
|
||||
|
||||
The corrected host boundary derives a non-secret, device-scoped profile ID from
|
||||
the selected SSID. The reviewed client contains per-device `WiFiAP_SSID` and
|
||||
`WiFiAP_Password` fields, but the 2026-07-20 review of the exact official K1
|
||||
`3.0.2` firmware recovered their upstream source: the scanner's bundled
|
||||
NetworkManager AP script assigns one firmware-constant WPA2 material, while
|
||||
`lixel_nman` constructs `XGR-` plus six device-identity characters with a MAC
|
||||
fallback. It is neither an iPhone credential nor a per-device secret.
|
||||
|
||||
The laboratory implementation can install a firmware-scoped credential source from the
|
||||
authenticated official archive. The offline importer validates the exact
|
||||
SHA-256, streams the bounded application-partition range, requires one valid AP
|
||||
declaration and writes the value to the OS secure store through helper stdin.
|
||||
On macOS, the Keychain helper materializes the selected device profile from
|
||||
that opaque source before any BLE write. A missing provider fails closed. The
|
||||
browser, API, argv, logs, manifests and evidence never receive the secret; the
|
||||
importer's short-lived mutable buffer is zeroized after the Keychain handoff.
|
||||
|
||||
The host-network boundary, rather than the XGRIDS frontend, owns platform
|
||||
association. Browsers expose no Wi-Fi join API, and Apple's iOS
|
||||
`NEHotspotConfiguration` consent flow is unavailable on macOS. The current
|
||||
implementation therefore uses a short-lived Swift/CoreWLAN + macOS Keychain
|
||||
helper; Windows Credential Manager and Linux Secret Service adapters remain
|
||||
separate platform work. The helper performs repeated read-only exact-SSID scans
|
||||
inside one 15-second discovery window and at most one association. It never
|
||||
repeats the BLE command, guesses a password or treats `7f01` as a credential-read
|
||||
command. The credential-bearing 99-byte station-provisioning frame and fixed
|
||||
100-byte AP-enable frame are separate reviewed payloads.
|
||||
|
||||
No reviewed BLE characteristic or response supplies the AP credential. That
|
||||
does not prove a universal negative for every vendor build, but it means the
|
||||
current plugin has no evidence-backed device-side credential acquisition path.
|
||||
The owner also observed no explicit device/account pairing in the normal
|
||||
LixelGo onboarding flow; this is consistent with a firmware-defined AP secret,
|
||||
but does not establish account-wide authorization for arbitrary scanners.
|
||||
|
||||
Connection verification refreshes the session-scoped lease with the same
|
||||
read-only BLE status operation. It does not write a characteristic, re-provision
|
||||
Wi-Fi, scan the subnet, change a host route, or touch VPN configuration. The
|
||||
later canonical MQTT session supplies the real data-plane connection and live
|
||||
`DeviceInfo` identity check. A BLE lease observation is therefore not by itself
|
||||
a claim that MQTT/RTSP is reachable.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The UI now represents all three intended directions instead of disabled
|
||||
placeholders.
|
||||
- Quick Connect can temporarily remove the Mac from its previous Wi-Fi network.
|
||||
macOS may require Wi-Fi/location permission for the process running Mission
|
||||
Core.
|
||||
- Quick Connect can temporarily remove the controlling host from its previous
|
||||
Wi-Fi network. The operating system may require Wi-Fi/location permission.
|
||||
- A new host needs a separate exact-firmware provider installation. The current
|
||||
application does not obtain it from BLE and does not bootstrap it by itself.
|
||||
- Automatic firmware download, iPhone extraction and hard-coded credential
|
||||
delivery are explicitly rejected product routes. Windows/Linux adapters are
|
||||
therefore not scheduled for this Quick Connect path.
|
||||
- Direct Connect requires an already-running hotspot and a controller route;
|
||||
Mission Core does not create or manage that hotspot.
|
||||
- The application-control, START/STOP and raw-first acquisition protocol is
|
||||
unchanged after a target address is admitted.
|
||||
- Direct Connect and Mission Core Quick Connect remain explicitly pending one
|
||||
owner-operated physical acceptance cycle each. Offline tests cannot promote
|
||||
those claims.
|
||||
- Direct Connect remains explicitly pending one owner-operated physical
|
||||
acceptance cycle. Quick Connect AP activation and host association are
|
||||
physically accepted only on the prepared K1/FW 3.0.2 Mac stand. Bridge is the
|
||||
only accepted portable product path in this matrix.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,627 @@
|
|||
# Mission Core handoff: AI worker, external perception and K1 Quick Connect
|
||||
|
||||
Date of factual refresh: 2026-07-19 21:15 MSK.
|
||||
|
||||
> 2026-07-20 superseding update: official firmware analysis proved that the
|
||||
> exact K1 `3.0.2` AP credential is firmware-constant and that the SSID follows
|
||||
> an identity/MAC rule. A prepared Mac completed AP-ready, CoreWLAN association
|
||||
> and the normal control lifecycle. This did **not** solve clean-host bootstrap:
|
||||
> the provider had been separately imported from the firmware archive. The
|
||||
> owner closed this product branch, rejected automatic firmware download,
|
||||
> iPhone extraction and hard-coding, retained the working UI/Keychain state and
|
||||
> selected Bridge/direct-LAN as the continuing route. Canonical details:
|
||||
> `docs/lab/004_K1_FW302_AP_CREDENTIAL_PROVIDER_20260720.redacted.md`.
|
||||
|
||||
This document is a transition context for a new engineering chat. It separates
|
||||
committed/accepted work from current uncommitted K1 work and from future plans.
|
||||
It contains operational addresses, ports, repository paths and public
|
||||
fingerprints, but no password, private key, token, device secret, raw capture or
|
||||
camera image.
|
||||
|
||||
## Executive state
|
||||
|
||||
Mission Core currently has two independently meaningful physical results:
|
||||
|
||||
1. The Mac/K1 control and archive path is accepted for one owner-controlled
|
||||
XGRIDS LixelKity K1, firmware 3.0.2, Bridge/direct-LAN, handheld, GNSS without
|
||||
RTK. Mission Core can run the reviewed MQTT bootstrap, START acquisition,
|
||||
display live points, send explicit STOP, observe final READY/unbound and open
|
||||
the durable Rerun/camera archive.
|
||||
2. A replaceable Windows RTX 4090 worker is accepted for one recorded,
|
||||
camera-only perception job. The Mac publishes a bounded immutable
|
||||
`missioncore.compute-job/v1`; Windows verifies it, runs pinned YOLOX-S through
|
||||
pinned Triton, publishes `missioncore.compute-result/v1`; Mission Core
|
||||
revalidates and projects the optional result into the matching saved Rerun
|
||||
recording.
|
||||
|
||||
This is not yet a live autonomous-vehicle stack. The worker has no K1 authority,
|
||||
Triton is not exposed to the LAN, the current transfer is SSH/SCP laboratory
|
||||
bootstrap, Tailscale is not installed, and live fan-out/fusion/tracking/free
|
||||
space/control are not accepted.
|
||||
|
||||
The current K1 Quick Connect implementation is a separate uncommitted worktree.
|
||||
It proved BLE AP activation, exact AP-ready observation and prepared-host native
|
||||
macOS association/control. It remains laboratory-only because a clean host has
|
||||
no autonomous evidence-backed credential source. The implementation and current
|
||||
machine state are retained, but Bridge/direct-LAN is the product route.
|
||||
|
||||
## Canonical topology and authority
|
||||
|
||||
```text
|
||||
XGRIDS K1
|
||||
| BLE / MQTT / RTSP
|
||||
v
|
||||
Mission Core Edge on Mac
|
||||
- sole K1 command authority
|
||||
- sole acquisition lifecycle owner
|
||||
- authoritative raw .k1mqtt + camera fMP4 archive
|
||||
- durable session catalog and Rerun projection
|
||||
|
|
||||
| bounded immutable missioncore.compute-job/v1
|
||||
| current lab transport: SSH/SCP
|
||||
v
|
||||
Mission Core AI Server Worker on Windows / RTX 4090
|
||||
- verifies the complete job and every digest
|
||||
- reconstructs and decodes only the admitted camera epoch
|
||||
- calls local pinned Triton / pinned model
|
||||
- atomically publishes missioncore.compute-result/v1
|
||||
|
|
||||
v
|
||||
Mission Core Edge validates result and creates optional derived Rerun layer
|
||||
```
|
||||
|
||||
The Windows machine is an executor, not a second Mission Core. It does not know
|
||||
K1 credentials, IP, MQTT topics or RTSP endpoints and cannot discover,
|
||||
provision, START or STOP the scanner. Derived inference never replaces or
|
||||
rewrites the Mac source archive.
|
||||
|
||||
## Why the original architecture proposal was narrowed
|
||||
|
||||
The supplied architecture review was accepted as a boundary, not as a mandate
|
||||
to install every named technology immediately. The following principles were
|
||||
kept:
|
||||
|
||||
- one vendor owner for K1 control and source streams;
|
||||
- immutable raw-first evidence and separately versioned derived data;
|
||||
- replaceable external compute;
|
||||
- local mission/safety authority rather than a remote GPU dependency;
|
||||
- explicit separation of command/state, real-time flow and archive transport;
|
||||
- declarative, reproducible deployment built mostly from mature components.
|
||||
|
||||
The following were deliberately deferred because they would precede the first
|
||||
vertical evidence:
|
||||
|
||||
- custom universal Mission Core Node Runtime;
|
||||
- custom cross-platform installer, node console, OTA, model package format and
|
||||
generic scheduler;
|
||||
- ROS 2 before a real onboard computer, robot state and actuator/autopilot
|
||||
contract exist;
|
||||
- BehaviorTree.CPP before a local mission executor is needed;
|
||||
- Zenoh until direct/routed transport can be compared on identical data;
|
||||
- MCAP as a replacement for the accepted K1 raw source;
|
||||
- dora-rs as a control/safety foundation;
|
||||
- LM Studio for the camera/LiDAR detector vertical.
|
||||
|
||||
Triton was selected for the first executor because it already provides pinned
|
||||
model repositories, HTTP/gRPC, health, metrics and replaceable inference
|
||||
backends. Mission Core continues to own its northern job/result contracts.
|
||||
|
||||
## Windows host identity and hardware
|
||||
|
||||
Live read-only inventory at the refresh time:
|
||||
|
||||
- host: `<worker-host>`;
|
||||
- interactive/SSH account: `<worker-host>\<worker-user>`;
|
||||
- OS: Windows 11 Pro x64, version 10.0.26200, build 26200;
|
||||
- last boot: 2026-07-19 15:38:18 MSK;
|
||||
- PowerShell: 5.1.26100.7705;
|
||||
- CPU: Intel Core i9-13900KF, 24 cores / 32 logical processors;
|
||||
- RAM: 95.85 GiB;
|
||||
- GPU: NVIDIA GeForce RTX 4090, 24,564 MiB VRAM, compute capability 8.9;
|
||||
- NVIDIA driver: Studio 610.47 WHQL;
|
||||
- live GPU snapshot: about 10,229 MiB used, 13,910 MiB free, 41 C and 68.68 W;
|
||||
- disk `C:`: NTFS, 1,861.99 GiB total, 1,700.86 GiB free;
|
||||
- disk `D:`: NTFS label `DC`, 1,863.00 GiB total, 411.44 GiB free;
|
||||
- Git: 2.53.0.windows.1;
|
||||
- Codex CLI: 0.142.1;
|
||||
- FFmpeg: 8.0.1 essentials build;
|
||||
- native host Python: not installed; Windows Store aliases exist but do not
|
||||
resolve to a Python runtime;
|
||||
- Tailscale: not installed.
|
||||
|
||||
The driver was upgraded from 591.86 to 610.47 after the first Triton startup
|
||||
showed CUDA Minor Version Compatibility mode. The official NVIDIA installer was
|
||||
978,481,008 bytes, SHA-256
|
||||
`59AC4A1659664AAD0A6FC525E5DF99B3FA76887BDE663F9E36E0E7EBB5DBA937`,
|
||||
had a valid NVIDIA Corporation Authenticode signature and returned exit code 0.
|
||||
After reboot, the compatibility warning disappeared and the full compute smoke
|
||||
passed.
|
||||
|
||||
## WSL and Docker substrate
|
||||
|
||||
- WSL: 2.7.8.0;
|
||||
- default distribution: Ubuntu 24.04, WSL version 2;
|
||||
- kernel: 6.18.33.1-microsoft-standard-WSL2;
|
||||
- Docker Desktop distribution: WSL2 and currently running;
|
||||
- Docker Desktop: 4.78.0;
|
||||
- Docker Engine/client: 29.5.3;
|
||||
- Docker Compose: 5.1.4;
|
||||
- Docker Linux VM: 32 CPUs and approximately 50.43 GB memory;
|
||||
- storage driver: overlayfs;
|
||||
- NVIDIA container runtime is registered; default runtime remains `runc` and
|
||||
the Compose service explicitly requests all GPUs.
|
||||
|
||||
Docker Desktop's engine-owned image/layer VHDX remains on `C:`. Mission Core
|
||||
application state is on `D:`. Moving Docker's VHDX was intentionally not made a
|
||||
pilot prerequisite.
|
||||
|
||||
## Isolated Windows directory layout
|
||||
|
||||
Root: `D:\NDC_MISSIONCORE`.
|
||||
|
||||
```text
|
||||
D:\NDC_MISSIONCORE\
|
||||
README.md
|
||||
workspace\
|
||||
mission-core-compute\ versioned deployment and worker repository
|
||||
runtime\
|
||||
cache\ disposable downloads/build cache
|
||||
derived\ decoded intermediates and immutable results
|
||||
docker-cli\ isolated Docker CLI state for SSH automation
|
||||
jobs\ bounded transferred compute jobs
|
||||
logs\ service/task transcripts
|
||||
models\ Triton model repository and weights
|
||||
tmp\ disposable temporary data
|
||||
secrets\ machine-local credentials, never committed
|
||||
```
|
||||
|
||||
The root itself is not a Git repository. Only
|
||||
`D:\NDC_MISSIONCORE\workspace\mission-core-compute` is versioned. Runtime
|
||||
weights, jobs, results, caches, logs and secrets are outside Git.
|
||||
|
||||
## Windows worker repository
|
||||
|
||||
Repository:
|
||||
`D:\NDC_MISSIONCORE\workspace\mission-core-compute`.
|
||||
|
||||
Current state:
|
||||
|
||||
- branch: `main`;
|
||||
- HEAD: `a3c36c3 feat(perception): prove recorded Triton vertical`;
|
||||
- previous commits:
|
||||
- `3c62815 guard native CUDA runtime compatibility`;
|
||||
- `d1043d0 qualify CUDA 13.3 driver path`;
|
||||
- `8edd003 bootstrap Windows GPU compute node`;
|
||||
- working tree: clean;
|
||||
- Git remote: none configured.
|
||||
|
||||
Versioned content includes `compose.yaml`, `.env.example`, model config,
|
||||
baseline/decision/acceptance docs, bounded PowerShell start/stop/test/handoff
|
||||
scripts and `worker/run_yolox_recorded.py`.
|
||||
|
||||
No custom Windows service, host Python environment, LM Studio integration,
|
||||
universal model loader or second Mission Core instance was installed.
|
||||
|
||||
## Active Triton deployment
|
||||
|
||||
Container: `mission-core-triton`, Compose project `mission-core-compute`.
|
||||
|
||||
Pinned image:
|
||||
|
||||
`nvcr.io/nvidia/tritonserver:26.06-py3@sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794`
|
||||
|
||||
The local image reports Triton 2.70.0 and a roughly 22.7 GB image size.
|
||||
|
||||
Effective deployment properties:
|
||||
|
||||
- `gpus: all`;
|
||||
- shared memory: 2 GiB;
|
||||
- model control: explicit;
|
||||
- automatic model config completion: disabled;
|
||||
- strict readiness: enabled;
|
||||
- server does not exit for one model error;
|
||||
- HTTP 8000, gRPC 8001 and metrics 8002 enabled;
|
||||
- every host binding is loopback-only: `127.0.0.1:8000-8002`;
|
||||
- `D:\NDC_MISSIONCORE\runtime\models` is mounted read-only at `/models`;
|
||||
- `no-new-privileges:true`;
|
||||
- container restart policy: `no`;
|
||||
- health check: HTTP `/v2/health/ready` every 5 seconds;
|
||||
- current state: running and healthy;
|
||||
- live, ready and metrics checks: HTTP 200;
|
||||
- GPU metrics present;
|
||||
- CUDA compatibility warning absent;
|
||||
- deprecated model-config warning absent.
|
||||
|
||||
The committed `.env` contains only the loopback bind and pinned Triton image.
|
||||
No credential is required for the current loopback-only service.
|
||||
|
||||
## Startup, shutdown and task bridge
|
||||
|
||||
The normal operator scripts are:
|
||||
|
||||
```powershell
|
||||
Set-Location D:\NDC_MISSIONCORE\workspace\mission-core-compute
|
||||
.\scripts\Start-MissionCoreCompute.ps1
|
||||
.\scripts\Test-MissionCoreCompute.ps1
|
||||
.\scripts\Stop-MissionCoreCompute.ps1
|
||||
```
|
||||
|
||||
`Start-MissionCoreCompute.ps1` uses the node-local Docker CLI directory,
|
||||
targets Docker Desktop's Linux engine pipe, creates loopback-only `.env` from
|
||||
the example when absent, starts Docker Desktop when needed, performs Compose
|
||||
pull/up and waits for Triton readiness.
|
||||
|
||||
Docker Desktop's Windows credential helper rejects the OpenSSH network-logon
|
||||
token even for public image pulls. The reviewed workaround is not a second
|
||||
deployment path: `Invoke-StartAsInteractiveUser.ps1` creates/starts the
|
||||
on-demand `MissionCore-StartCompute` task in the logged-on user's limited
|
||||
interactive token. It executes the same committed start script and writes
|
||||
`D:\NDC_MISSIONCORE\runtime\logs\start-compute-latest.log`.
|
||||
|
||||
Scheduled task state:
|
||||
|
||||
- `MissionCore-DockerDesktop`: enabled logon trigger for the interactive user,
|
||||
limited privilege, currently Running;
|
||||
- `MissionCore-StartCompute`: no trigger, on-demand only, last result 0,
|
||||
currently Ready;
|
||||
- Triton Compose restart policy is `no`.
|
||||
|
||||
Therefore Docker Desktop starts at user logon, but Triton is not a fully
|
||||
autonomous reboot-surviving service. It is started explicitly/on demand.
|
||||
|
||||
## Model repository
|
||||
|
||||
Accepted model: official YOLOX-S ONNX release 0.1.1rc0, Apache-2.0.
|
||||
|
||||
- model path: `runtime\models\yolox_s\1\model.onnx`;
|
||||
- byte length: 35,858,002;
|
||||
- SHA-256:
|
||||
`C5C2D13E59AE883E6AF3B45DAEA64AF4833A4951C92D116EC270D9DDBE998063`;
|
||||
- Triton platform: ONNX Runtime GPU;
|
||||
- model version: 1 only;
|
||||
- input: FP32 `[1,3,640,640]`, name `images`;
|
||||
- output: FP32 `[1,8400,85]`, name `output`;
|
||||
- one GPU instance;
|
||||
- preprocessing: bilinear resize, top-left letterbox, BGR, pad 114;
|
||||
- postprocessing: official YOLOX grid/stride decode, score 0.25, NMS 0.45,
|
||||
COCO-80.
|
||||
|
||||
The model is useful only as a contract/timing smoke. It is not accepted for
|
||||
navigation, obstacle avoidance, free-space or safety decisions.
|
||||
|
||||
## Accepted recorded job and result
|
||||
|
||||
Source Mission Core session:
|
||||
`20260718T201659Z_viewer_live` / display name `TEST007`.
|
||||
|
||||
Accepted input:
|
||||
|
||||
- source: `sensor.camera.left`, physical codec epoch 1;
|
||||
- job: `recorded-camera-fae25b92ecf645c2c9765dd4`;
|
||||
- schema: `missioncore.compute-job/v1`;
|
||||
- full input SHA-256:
|
||||
`fae25b92ecf645c2c9765dd4b4963f23cf8c101f22fd5a2e14a104f0b447140d`;
|
||||
- 59 digest-bound files, 4,567,569 package bytes;
|
||||
- reconstructed H.264 stream: 4,549,458 bytes;
|
||||
- stream SHA-256:
|
||||
`bceca577f762fdb79c4e8901e5c2a2330fd07d24dc091b1b703c5af4010d8a56`;
|
||||
- decode: H.264 High, 800x600, 56 frames, 5.497 seconds;
|
||||
- synchronization: `host-arrival-best-effort`;
|
||||
- frame timestamps are made strictly increasing from best-effort decode time;
|
||||
this is not calibrated camera/LiDAR sensor time.
|
||||
|
||||
Accepted result:
|
||||
|
||||
- result:
|
||||
`result-5484a72e81192b19f4e1da2a2dcfd10c876c277ff3e96c6d60cbc9d917b9f604`;
|
||||
- schema: `missioncore.compute-result/v1`;
|
||||
- detection schema: `missioncore.object-detections/v1`;
|
||||
- 56/56 frames processed;
|
||||
- 54 frames with detections;
|
||||
- 56 generic COCO `person` detections;
|
||||
- detection artifact: 23,802 bytes;
|
||||
- detection SHA-256:
|
||||
`71b291616e87bbdd2b7a295b1e00dac2ff3e60c627e63b72a27ba802c32a3ae0`;
|
||||
- client-observed inference latency: mean 15.611 ms, p50 15.001 ms,
|
||||
p95 23.728 ms, max 31.099 ms.
|
||||
|
||||
Triton currently reports 168 successful inference requests, 0 failed. Three
|
||||
56-frame executions account for that count. The final exact repeated command
|
||||
reused the already published content-addressed result and did not increase the
|
||||
counter, proving idempotent reuse.
|
||||
|
||||
The first runner attempt completed inference but failed its post-inference EOF
|
||||
guard. No partial result was published. The runner was corrected before the
|
||||
accepted immutable result was produced.
|
||||
|
||||
## Rerun result projection on the Mac
|
||||
|
||||
Mission Core revalidates exact session/job/result binding, all digests,
|
||||
timestamps, dimensions and frame count before projection. It builds one complete
|
||||
RRF2 derived overlay, approximately 62.55 MB, cached privately by
|
||||
result/recording identity.
|
||||
|
||||
The browser accepts the overlay on an isolated Rerun channel. Missing or invalid
|
||||
perception returns no optional layer and never replaces the base point-cloud
|
||||
recording. In TEST007 the `Распознавание` switch showed the real recorded left
|
||||
camera frame with `person · 34%` Boxes2D; `Облако точек` returned to the base 3D
|
||||
view.
|
||||
|
||||
Committed Mac work:
|
||||
|
||||
- `648d5bc feat(compute): add bounded camera job contract`;
|
||||
- `31fc4f6 docs(perception): record external worker acceptance`;
|
||||
- `2b53168 feat(perception): project recorded results into Rerun`;
|
||||
- `ada2a55 docs(perception): record Rerun projection acceptance`.
|
||||
|
||||
Mac `main` is currently at `ada2a55`; `origin/main` remains at `75d2e5d`, so the
|
||||
four perception commits are local and not pushed.
|
||||
|
||||
## Other active workloads on the RTX host
|
||||
|
||||
The GPU workstation is shared. These containers are not part of Mission Core:
|
||||
|
||||
- `sentinel-frigate`, image `ghcr.io/blakeblackshear/frigate:0.17.2-tensorrt`,
|
||||
healthy, publishes host ports 5000, 8554, 8555 TCP/UDP and 8971;
|
||||
- `sentinel-ollama`, image `ollama/ollama:latest`, publishes host port 11434 and
|
||||
has no configured container health check.
|
||||
|
||||
Their current resource use contributes to the roughly 10 GiB observed GPU
|
||||
allocation. Mission Core currently has no resource reservation, scheduler or
|
||||
admission policy against these workloads. Long-epoch and live performance tests
|
||||
must record this co-tenancy or isolate it deliberately.
|
||||
|
||||
## SSH access from the Mac
|
||||
|
||||
Windows OpenSSH was installed through official winget package
|
||||
`Microsoft.OpenSSH.Preview` 10.0.0.0 because Windows Feature-on-Demand failed:
|
||||
|
||||
- `Add-WindowsCapability`: Access denied;
|
||||
- DISM with `/LimitAccess`: `0x800f0912`, source files not found;
|
||||
- `wuauserv` startup/ACL changes were denied by host policy.
|
||||
|
||||
Current Windows SSH facts:
|
||||
|
||||
- service: `sshd`, Running, Automatic;
|
||||
- binary: `C:\Program Files\OpenSSH\sshd.exe`, version 10.0.0.0;
|
||||
- listen: `0.0.0.0:22` and `[::]:22`;
|
||||
- ED25519 host fingerprint:
|
||||
`SHA256:LgvblT9h+5KJFtkDq6JWGUyvgpiFyeL6cztVVMY0ZQM`;
|
||||
- public-key authentication: enabled;
|
||||
- password authentication: also enabled;
|
||||
- strict modes: enabled;
|
||||
- max authentication attempts: 6.
|
||||
|
||||
The Mac connection uses a key and strict pinning, but the server itself is not
|
||||
yet key-only. Disabling server password authentication is an explicit future
|
||||
hardening action, not a completed fact.
|
||||
|
||||
Mac SSH alias in `~/.ssh/config`:
|
||||
|
||||
```sshconfig
|
||||
Host mission-gpu
|
||||
HostName <worker-host>.local
|
||||
User <worker-user>
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
IdentitiesOnly yes
|
||||
HostKeyAlias mission-gpu
|
||||
UserKnownHostsFile ~/.ssh/known_hosts_mission_core
|
||||
StrictHostKeyChecking yes
|
||||
ServerAliveInterval 15
|
||||
ServerAliveCountMax 3
|
||||
```
|
||||
|
||||
Connection and safe read-only smoke:
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes mission-gpu whoami
|
||||
ssh -o BatchMode=yes mission-gpu \
|
||||
'powershell.exe -NoProfile -NonInteractive -Command "$env:COMPUTERNAME"'
|
||||
```
|
||||
|
||||
The dedicated known-hosts entry matches the Windows ED25519 fingerprint above.
|
||||
No private key or password is stored in this repository or Ops.
|
||||
|
||||
## Windows network and firewall
|
||||
|
||||
Current physical Wi-Fi:
|
||||
|
||||
- SSID/profile: `<owner-lan-ssid>`;
|
||||
- interface: `Беспроводная сеть`;
|
||||
- category: Private;
|
||||
- IPv4: `<worker-lan-ip>/<owner-lan-prefix>`;
|
||||
- IPv4 connectivity: Internet.
|
||||
|
||||
VPN profile:
|
||||
|
||||
- name: `Сеть 5`;
|
||||
- interface: `hidemy.name VPN OpenVPN Adapter`;
|
||||
- category: Public;
|
||||
- IPv4: `<vpn-assigned-ip>/<vpn-prefix>`.
|
||||
|
||||
Windows Firewall is enabled for Private and Public profiles. The active custom
|
||||
SSH rule is:
|
||||
|
||||
- name: `MissionCore-SSH-From-Mac`;
|
||||
- display name: `MISSION CORE SSH from Mac`;
|
||||
- enabled, inbound allow, Private only;
|
||||
- interface: `Беспроводная сеть` only;
|
||||
- protocol/local port: TCP/22;
|
||||
- remote address: `LocalSubnet`;
|
||||
- local address: Any.
|
||||
|
||||
The broad MSI-created `OpenSSH SSH Server Preview (sshd)` inbound rule is
|
||||
disabled. The current custom rule is broader than the original single Mac IP
|
||||
because it admits the local subnet, but it remains confined to the Private Wi-Fi
|
||||
interface and does not apply to the Public VPN profile.
|
||||
|
||||
Triton ports 8000-8002 listen only on 127.0.0.1. No Windows firewall rule exposes
|
||||
them to the LAN. Consequently the Mac cannot yet call Triton directly; the
|
||||
accepted recorded path transfers bounded artifacts through SSH/SCP and executes
|
||||
the runner on Windows.
|
||||
|
||||
The current worker IPv4 `<worker-lan-ip>` previously appeared as a K1 target in
|
||||
historical TEST007 evidence. Before concurrent K1/worker LAN exposure, reserve
|
||||
non-conflicting addresses or prove topology separation. Do not expose Triton on
|
||||
this address until that conflict and the source-restricted firewall contract are
|
||||
reviewed.
|
||||
|
||||
## Current K1 Quick Connect state
|
||||
|
||||
The K1 plugin exposes three explicit local connection directions:
|
||||
|
||||
- Bridge/direct-LAN: K1 joins an existing shared network; physically accepted;
|
||||
- Direct Connect/controller-hotspot: K1 joins the controller's network;
|
||||
implemented/offline-verified, not physically accepted;
|
||||
- Quick Connect/device-AP: K1 becomes the AP and a prepared Mac joins it;
|
||||
physically accepted on the current host, retained as laboratory-only.
|
||||
|
||||
The reviewed LixelGO flow is:
|
||||
|
||||
1. keep one BLE connection open;
|
||||
2. write one reviewed 100-byte AP-enable frame (99 zero bytes plus final 1);
|
||||
3. poll `7f02` until the live status reports `WIFI_AP`, baseline address
|
||||
`192.168.56.1` and the AP-ready byte changes from 0 to nonzero;
|
||||
4. while BLE remains alive, use the native OS Wi-Fi API to scan exact SSID and
|
||||
associate;
|
||||
5. continue to K1 network/control only after association.
|
||||
|
||||
The earlier evidence session
|
||||
`.runtime/mission-core/evidence/sessions/20260719T173132Z_viewer_k1_ap_association`
|
||||
proved AP activation, AP-ready and native SSID discovery but stopped at the
|
||||
secure prompt. A later prepared-host session
|
||||
`.runtime/mission-core/evidence/sessions/20260719T220850Z_viewer_k1_ap_association`
|
||||
completed one CoreWLAN association and admitted `192.168.56.1`; the normal UI
|
||||
then completed control/acquisition. The operator disconnected afterward only to
|
||||
restore the external chat route.
|
||||
|
||||
Final credential findings:
|
||||
|
||||
- LixelGO persists `WiFiAP_SSID` and `WiFiAP_Password` in a per-device profile;
|
||||
- the Unity flow later passes those profile values to the native join path;
|
||||
- no password derivation from DeviceInfo, serial, BLE UUID or AP SSID was found;
|
||||
- official K1 `3.0.2` firmware contains one firmware-constant AP credential and
|
||||
constructs the SSID from device identity with a MAC fallback;
|
||||
- the reviewed BLE exchange enables the AP and reports readiness but exposes no
|
||||
observed credential response;
|
||||
- LixelGo login and the scanner AP are separate layers. The owner performed no
|
||||
explicit hardware-to-account pairing, which is consistent with the local
|
||||
firmware-defined credential, but does not prove universal account access;
|
||||
- the successful Mission Core run depended on a separate firmware import into
|
||||
this Mac's Keychain. A copied build on a clean Mac would fail before BLE.
|
||||
|
||||
The owner closed this branch. Do not download firmware during connection, seek
|
||||
the value from iPhone, hard-code it, or present machine-local Keychain state as
|
||||
portable automation. Keep the existing UI, implementation, Keychain/provider
|
||||
state and evidence intact. Continue through Bridge/direct-LAN.
|
||||
|
||||
## Current Mac repository and runtime state
|
||||
|
||||
Repository:
|
||||
`<mission-core-repository>`.
|
||||
|
||||
- branch: `main`;
|
||||
- HEAD: `ada2a55`;
|
||||
- `origin/main`: `75d2e5d`;
|
||||
- local Mission Core HTTP service: `127.0.0.1:8000`;
|
||||
- current health: service OK, one of one plugin runtimes ready;
|
||||
- current local server PID at 2026-07-20 closure refresh: 97887;
|
||||
- worker/perception commits are clean and local;
|
||||
- K1 Quick Connect changes are uncommitted and make the worktree dirty.
|
||||
|
||||
The dirty set spans K1 docs/profile/frontend, native CoreWLAN helper,
|
||||
BLE AP activation, host-network adapter and tests. It must not be reset or
|
||||
cleaned because it contains the current physical Quick Connect implementation
|
||||
and evidence-aligned fixes.
|
||||
|
||||
Latest validation of the current dirty tree:
|
||||
|
||||
- complete backend suite: passed;
|
||||
- Ruff: clean;
|
||||
- mypy: clean across 75 source files;
|
||||
- frontend: 140 unit tests passed;
|
||||
- TypeScript typecheck: clean;
|
||||
- production Vite build: clean;
|
||||
- Swift helper parse: clean;
|
||||
- `git diff --check`: clean.
|
||||
|
||||
## What is accepted, partial and not started
|
||||
|
||||
Accepted:
|
||||
|
||||
- physical K1 Bridge control lifecycle START/live/STOP/READY;
|
||||
- durable session catalog, point cloud and camera playback;
|
||||
- bounded content-addressed recorded camera job;
|
||||
- Windows digest verification and deterministic 56-frame decode;
|
||||
- pinned Triton/YOLOX-S inference on RTX 4090;
|
||||
- immutable content-addressed result and exact-repeat reuse;
|
||||
- validated optional Rerun camera/Boxes2D projection;
|
||||
- strict SSH host-key pinning and local-lab access path;
|
||||
- reproducible Windows worker repository and operator scripts.
|
||||
|
||||
Partial/laboratory-only:
|
||||
|
||||
- Quick Connect is physically accepted on one prepared Mac but has no clean-host
|
||||
credential bootstrap and is closed as a product route;
|
||||
- Windows is a reproducible lab worker but Triton is loopback-only and manually
|
||||
started;
|
||||
- SSH is source-restricted by firewall and the Mac uses a key, but sshd still
|
||||
permits passwords;
|
||||
- the worker result is recorded-only and camera-only;
|
||||
- GPU resource use is shared with unrelated Frigate/Ollama workloads.
|
||||
|
||||
Not accepted/not implemented:
|
||||
|
||||
- Direct Connect physical test;
|
||||
- second K1 or firmware portability matrix;
|
||||
- RTK, UGV, UAV, calibrated sensor time or camera/LiDAR extrinsics;
|
||||
- long TEST007 epoch throughput/resource qualification;
|
||||
- bounded live inference, queue/drop policy and recovery testing;
|
||||
- direct authenticated LAN worker transport;
|
||||
- Tailscale/routed worker transport and deny-by-default grants;
|
||||
- point-cloud inference, camera/LiDAR fusion, tracking, segmentation or free
|
||||
space;
|
||||
- actuator/autopilot integration, mission executor or safety authority;
|
||||
- production process supervision, operation journal, retention, encryption,
|
||||
replication and resource scheduler;
|
||||
- custom installer/node console/model marketplace, intentionally deferred.
|
||||
|
||||
## Recommended next gates in exact order
|
||||
|
||||
1. Continue K1 operation through the accepted Bridge/direct-LAN path. Preserve
|
||||
the prepared Quick Connect implementation and evidence without extending it.
|
||||
2. Commit the retained K1 mechanism/evidence/docs in logical commits when the
|
||||
owner chooses; do not claim clean-host portability.
|
||||
3. Qualify the 206-second TEST007 camera epoch on the same immutable model
|
||||
profile, recording transfer, decode, inference, GPU memory, queue depth and
|
||||
co-tenant effects.
|
||||
4. Define a direct authenticated worker endpoint that preserves the current
|
||||
`compute-job/result` schemas; reserve a non-conflicting worker address and add
|
||||
a source-restricted firewall rule before any LAN port exposure.
|
||||
5. Add bounded live fan-out from the sole Mac RTSP owner with explicit sampling,
|
||||
latest-wins/drop counters, deadlines, stale state and failure isolation.
|
||||
6. Kill/restart Triton, power off the worker and break transport while proving
|
||||
K1 control/raw archive continue and the UI reports degraded perception.
|
||||
7. Repeat the same endpoint contract through Tailscale; do not expose public
|
||||
Triton ports.
|
||||
8. Only after this evaluate tracking, segmentation/free-space and a LiDAR CPU
|
||||
processor. Keep every result versioned and non-safety until calibrated and
|
||||
separately accepted.
|
||||
9. Evaluate ROS 2/BehaviorTree only when an actual onboard computer,
|
||||
robot/autopilot and actuator/safety contract exist. Evaluate Zenoh only
|
||||
against the measured direct transport.
|
||||
|
||||
## Canonical local sources for the next chat
|
||||
|
||||
- `docs/10_EXTERNAL_PERCEPTION_WORKER.md` — job/result and Rerun contract;
|
||||
- `docs/adr/0014-bounded-external-perception-worker.md` — accepted architecture;
|
||||
- `docs/lab/005_FIRST_RECORDED_PERCEPTION_20260719.redacted.md` — physical
|
||||
recorded evidence;
|
||||
- `src/k1link/compute/jobs.py` — compute job creation/validation;
|
||||
- `src/k1link/compute/results.py` — result validation/Rerun projection inputs;
|
||||
- `docs/adr/0013-k1-local-connection-matrix.md` — K1 connection directions;
|
||||
- `src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py` — reviewed AP
|
||||
activation session;
|
||||
- `src/k1link/host_network/wifi.py` — OS Wi-Fi profile boundary;
|
||||
- `plugins/xgrids-k1/macos/associate_wifi.swift` — CoreWLAN and Keychain helper;
|
||||
- this handoff — exact current machine/runtime/SSH/partial-acceptance snapshot.
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
# Lab 004 — K1 FW 3.0.2 Quick Connect mechanism and closure
|
||||
|
||||
- Date: 2026-07-20
|
||||
- Device scope: one owner-controlled LixelKity K1
|
||||
- Firmware scope: exact `3.0.2`
|
||||
- Host: owner-controlled Apple-silicon Mac
|
||||
- Safety: offline firmware analysis followed by one explicit BLE/AP/host-association cycle
|
||||
- Secret policy: no AP credential value or digest appears in this report, Git, argv,
|
||||
logs, manifests or fixtures
|
||||
|
||||
## Official artifact
|
||||
|
||||
The full K1 `3.0.2` archive was downloaded from the XGRIDS international
|
||||
[K1 support page](https://www.xgrids.com/intl/support/download?page=K1). The
|
||||
local private artifact was `1,252,252,754` bytes with SHA-256
|
||||
`e5830feae54d586cdeda2824495d08598920dc9cf4541059d01f0efeb858a750`.
|
||||
It remains under the ignored `sessions/firmware-analysis/` tree with mode
|
||||
`0600`.
|
||||
|
||||
The archive contains a Rockchip `RKFW`/`RKAF` image. The reviewed partitions
|
||||
were extracted read-only as sparse ext4 images:
|
||||
|
||||
- `rootfs.img`: 4,294,967,296 bytes, SHA-256
|
||||
`b6e565fa1dd37d539a34b6fe92457bba6e793ab8db0ca216e1fe73a6602b975e`;
|
||||
- `apps.img`: 230,801,408 bytes, SHA-256
|
||||
`c4acb8d60b2e84d56487e5a110b41806fb2f4aa2f4b3cf6f1bc4d8b704b7e59a`.
|
||||
|
||||
No firmware was uploaded to the device and no filesystem was mounted
|
||||
read-write.
|
||||
|
||||
## Recovered mechanism
|
||||
|
||||
The application partition contains
|
||||
`/system/scripts/wifi_ap/wifi_ap.sh`. The script deletes prior Wi-Fi profiles,
|
||||
creates NetworkManager connection `WIFI_AP`, configures WPA2/RSN/CCMP, assigns
|
||||
`192.168.56.1/24`, installs one firmware-constant PSK and raises the AP.
|
||||
|
||||
The stripped AArch64 `lixel_nman` service invokes that script for AP mode. Its
|
||||
reviewed control flow constructs the SSID as `XGR-` plus six device-identity
|
||||
characters; if the expected ten-character identity is unavailable it falls
|
||||
back to the final three MAC bytes formatted as six hexadecimal characters. The
|
||||
owner-controlled scanner's observed BLE name and AP SSID match this rule.
|
||||
|
||||
Therefore, for exact K1 firmware `3.0.2`:
|
||||
|
||||
- the AP credential is not supplied by the AP-enable BLE frame;
|
||||
- it is not recovered from an iPhone or synchronized Wi-Fi history;
|
||||
- it is not per-device material;
|
||||
- it is an exact-firmware credential profile defined by the scanner itself.
|
||||
|
||||
This does not authorize extrapolation to another K1 firmware or XGRIDS model.
|
||||
|
||||
The owner workflow is consistent with this separation. LixelGo required an
|
||||
application account, but the operator performed no explicit scanner-to-account
|
||||
pairing, ownership confirmation or per-device credential enrollment. The
|
||||
available evidence therefore supports treating login as application access and
|
||||
the AP credential as local firmware behavior. It does not prove that one
|
||||
account is authorized for every XGRIDS device, and no such broader claim is
|
||||
made.
|
||||
|
||||
## Provider implementation
|
||||
|
||||
Mission Core now identifies the source as
|
||||
`xgrids.lixelkity-k1.quick-connect.fw-3.0.2.official-firmware.v1`.
|
||||
The offline importer:
|
||||
|
||||
1. requires the exact reviewed archive SHA-256;
|
||||
2. streams the nested Rockchip image without extracting the 4.57-GB container;
|
||||
3. scans only the bounded apps-partition range;
|
||||
4. requires exactly one syntactically valid NetworkManager PSK declaration;
|
||||
5. writes the short-lived value to the host credential store through stdin;
|
||||
6. zeroizes the mutable Python buffer and emits only redacted provider metadata.
|
||||
|
||||
On macOS the firmware-scoped material lives in Keychain. Before any device
|
||||
write, the CoreWLAN helper materializes the selected SSID's opaque device
|
||||
profile entirely inside Keychain. The browser/API receives neither the source
|
||||
material nor a password. A missing source fails before AP activation.
|
||||
|
||||
This is a working laboratory provider, not a portable product bootstrap. A new
|
||||
host needs the provider material installed before Quick Connect. Windows and
|
||||
Linux would additionally need their own secure-store and Wi-Fi adapters.
|
||||
|
||||
## Physical acceptance
|
||||
|
||||
After the firmware-scoped provider was installed, one six-second BLE scan found
|
||||
exactly one expected K1 candidate. Mission Core then performed:
|
||||
|
||||
1. one reviewed 100-byte AP-enable write;
|
||||
2. one bounded wait for the canonical byte-51 AP-ready transition;
|
||||
3. one exact-SSID CoreWLAN association using the Keychain-only profile.
|
||||
|
||||
The operation succeeded and admitted the reviewed K1 AP address
|
||||
`192.168.56.1`. No credential was entered or displayed. The operator then
|
||||
manually disconnected the Mac from the scanner AP to preserve the external
|
||||
chat connection; that later host action does not invalidate the completed
|
||||
association acceptance.
|
||||
|
||||
## Result and closure decision
|
||||
|
||||
The device-side mechanism and the prepared-host transport are physically
|
||||
accepted: Mission Core can enable the K1 AP, observe canonical readiness,
|
||||
associate one configured Mac and continue into the normal local control path.
|
||||
The result does **not** establish a self-contained, transferable plugin. The
|
||||
successful Mac had first received the firmware-scoped material through a
|
||||
separate administrative import; copying the current application to a clean Mac
|
||||
would fail closed with `credential-source-unavailable` before the BLE write.
|
||||
|
||||
On 2026-07-20 the owner rejected all remaining bootstrap variants for the
|
||||
product path:
|
||||
|
||||
- downloading the 1.25-GB firmware archive during first connection;
|
||||
- extracting or enrolling the value manually from an iPhone;
|
||||
- embedding or deriving an unversioned password in application source;
|
||||
- presenting the machine-local Keychain setup as cross-host automation.
|
||||
|
||||
Quick Connect remains in the interface and its current Mac Keychain/provider
|
||||
state is retained. No evidence, credential material or private firmware files
|
||||
were deleted. Development of credential acquisition is closed without claiming
|
||||
that the password can be read from BLE: the reviewed BLE exchange contains AP
|
||||
activation and status, but no observed credential response. The preferred and
|
||||
accepted product topology is now Bridge/direct-LAN, where Mission Core sends
|
||||
operator-selected LAN credentials to K1 through the already reviewed 99-byte
|
||||
provisioning frame.
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
# External perception stack review — 2026-07-20
|
||||
|
||||
## Objective
|
||||
|
||||
Select mature, reusable components for Mission Core's K1 camera/LiDAR pipeline
|
||||
instead of growing the LAB implementation into a bespoke stack by trial and
|
||||
error. The target outputs are reviewed 2D/dense semantic labels, classified 3D
|
||||
boxes in the point cloud, distance to objects, and a path to recorded and live
|
||||
execution.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep Rerun as the synchronized viewer and experiment evidence surface. Do not
|
||||
treat it as the perception engine. Use CVAT as the human-review and
|
||||
ground-truth control plane. Reuse NVIDIA's production inference patterns for
|
||||
LiDAR and multimodal execution, adapting only K1 data loading, timestamps, and
|
||||
factory calibration. Use open model frameworks as benchmark sources, not as a
|
||||
second production runtime.
|
||||
|
||||
The shortest credible route is:
|
||||
|
||||
1. Review LAB E2 drafts in CVAT and freeze a versioned ground-truth subset.
|
||||
2. Benchmark corrected/masked K1 images on that same subset.
|
||||
3. Replace heuristic 3D cube construction with a LiDAR-native PointPillars
|
||||
baseline.
|
||||
4. Serve the selected 2D and 3D models through the existing Triton worker and
|
||||
log outputs to Rerun.
|
||||
5. Gate camera/LiDAR BEVFusion on proven synchronization, calibration, and
|
||||
baseline metrics.
|
||||
|
||||
## Evidence from mature implementations
|
||||
|
||||
| Reference | Reusable practice | Mission Core use | Do not copy blindly |
|
||||
| --- | --- | --- | --- |
|
||||
| [Rerun](https://rerun.io/docs/overview/what-is-rerun) and its [examples](https://rerun.io/examples) | Time-aligned logging of images, point clouds, masks, 2D/3D boxes, trajectories, and model outputs | Viewer, debugging, playback, run evidence | It does not infer segmentation, depth, or boxes |
|
||||
| [OctoSense](https://github.com/anthonytec2/OctoSense) | Camera rectification before inference; RGB/LiDAR timestamp alignment; semantic labeling; dynamic-object masks; calibrated LiDAR projection; dataset packaging | Copy the pipeline shape and QA gates | It is primarily a dataset-generation recipe, not a drop-in real-time Mission Core runtime |
|
||||
| [DeepStream LiDAR 3D inference](https://docs.nvidia.com/metropolis/deepstream/7.1/text/DS_3D_Lidar_Inference.html) | PointPillars preprocessing, inference, postprocessing, and 3D box visualization in a production pipeline | First classified 3D-box baseline | Its sample data loader and calibration assumptions must be replaced with K1 adapters |
|
||||
| [DeepStream multimodal fusion](https://docs.nvidia.com/metropolis/deepstream/8.0/text/DS_3D_MultiModal_Lidar_Sensor_Fusion.html) | Camera/LiDAR preprocessing and BEVFusion deployment | Later accuracy/robustness stage after LiDAR-only baseline | Too expensive as the first 3D fix; requires trustworthy synchronization and extrinsics |
|
||||
| [MMDetection3D](https://github.com/open-mmlab/mmdetection3d) and [OpenPCDet](https://github.com/open-mmlab/OpenPCDet) | Reproducible 3D model/config baselines and pretrained checkpoints | Offline comparison and checkpoint selection | Do not introduce a parallel permanent serving stack beside Triton |
|
||||
| [TAO auto-label](https://docs.nvidia.com/tao/tao-toolkit/text/data_services/auto-label.html) | Assisted labeling and model-driven annotation | Candidate accelerator after the first reviewed GT set exists | Auto-label output is still draft data, not accuracy evidence |
|
||||
| [FiftyOne annotation integration](https://docs.voxel51.com/integrations/annotation.html) | Dataset curation, slice analysis, and CVAT round trips | Add when error analysis across many sessions becomes the bottleneck | Not needed to complete the first GT gate |
|
||||
| [Isaac ROS nvblox](https://github.com/NVIDIA-ISAAC-ROS/isaac_ros_nvblox) | GPU mapping, reconstruction, and cost-map production | Later navigation/mapping consumer | It does not replace object detection or classified 3D boxes |
|
||||
| [Isaac Sim camera sensors](https://docs.isaacsim.omniverse.nvidia.com/latest/sensors/isaacsim_sensors_camera.html) | Fisheye/OpenCV camera models and calibration-aware simulation | Synthetic regression cases after real-data metrics exist | Simulation is not a substitute for validating K1 factory calibration on recorded data |
|
||||
|
||||
## Selected stack boundaries
|
||||
|
||||
| Layer | Selected role | Current state | Gate before expansion |
|
||||
| --- | --- | --- | --- |
|
||||
| K1 ingestion | Preserve native timestamps, camera identity, point cloud, and vendor calibration | Recording works | Validate camera identity and projection on representative frames |
|
||||
| Image preprocessing | Stable optical-circle mask plus calibration-derived correction profile | LAB E2 mask/draft path exists | Freeze profile ID and compare corrected versus raw on identical frames |
|
||||
| 2D/dense perception | Generate prelabels, then evaluate against reviewed CVAT exports | LAB E2 drafts imported | Human two-pass review and class-level metrics |
|
||||
| 3D detection | LiDAR-native PointPillars baseline | Next model integration | K1 point format adapter, coordinate convention test, reviewed 3D subset |
|
||||
| Multimodal fusion | BEVFusion/DeepStream candidate | Deferred | Baselines, sync tolerance, extrinsic validation, GPU budget |
|
||||
| Serving | Existing Triton worker | Running | Pin model/config artifacts and record per-stage latency/resource metrics |
|
||||
| Visualization | Rerun | Running | Log reviewed labels and model outputs separately |
|
||||
| Annotation/GT | CVAT `v2.70.0` | Deployed on D; LAB E2 tasks imported | Two-pass review; export with immutable manifest and hashes |
|
||||
|
||||
## Configuration practices to adopt
|
||||
|
||||
- Rectify or calibration-map the fisheye image before model inference when the
|
||||
model expects a pinhole-like domain. Keep the raw frame alongside it.
|
||||
- Apply a cached optical-circle mask/profile by camera identity; do not
|
||||
recompute the mask for every frame.
|
||||
- Synchronize camera frames to LiDAR timestamps explicitly and record the
|
||||
chosen tolerance and dropped/unmatched frames.
|
||||
- Keep 2D detections, dense semantics, LiDAR-native 3D detections, and fused
|
||||
results as separate evidence streams. Fusion must not erase provenance.
|
||||
- Project 3D boxes into the camera for validation, but do not construct final
|
||||
3D boxes by simply extruding every 2D detection. That heuristic produces the
|
||||
overlapping-cube failure observed in LAB E1.
|
||||
- Record model identity, checkpoint hash, preprocessing profile, precision,
|
||||
batch size, GPU memory, stage latency, wall time, and output hashes for every
|
||||
material experiment.
|
||||
- Treat every model-produced label as `ground_truth=false` until reviewed and
|
||||
exported through the ground-truth gate.
|
||||
|
||||
## Next experiment gates
|
||||
|
||||
### LAB E2 review gate
|
||||
|
||||
- Review both imported tasks across the same 64 frames.
|
||||
- First pass: correct labels, masks, and missed/false objects.
|
||||
- Second pass: independent consistency review.
|
||||
- Export reviewed instance and semantic annotations with manifest hashes.
|
||||
|
||||
### LAB E3 image baseline
|
||||
|
||||
- Compare raw-circle-mask, rectified, and correction-profile inputs on the
|
||||
frozen reviewed subset.
|
||||
- Select by class metrics and temporal stability, not by visual preference.
|
||||
- Measure preprocessing, inference, postprocessing, and end-to-end time
|
||||
separately.
|
||||
|
||||
### LAB E4 3D baseline
|
||||
|
||||
- Integrate a PointPillars-compatible K1 point loader.
|
||||
- Lock coordinate axes, units, timestamps, and calibration version in the run
|
||||
manifest.
|
||||
- Evaluate classified 3D boxes on a small reviewed subset before any BEVFusion
|
||||
work.
|
||||
|
||||
## Rejected shortcuts
|
||||
|
||||
- Replacing Rerun with OctoSense: wrong boundary; OctoSense supplies useful
|
||||
pipeline patterns while Rerun remains the viewer.
|
||||
- Moving the full stack to Omniverse: useful for simulation and synthetic data,
|
||||
not the shortest path to recorded K1 accuracy.
|
||||
- Treating current semantic/instance drafts as GT: invalidates all subsequent
|
||||
model comparisons.
|
||||
- Starting with BEVFusion: adds synchronization, calibration, conversion, and
|
||||
runtime risks before the simpler LiDAR-native baseline is measured.
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
# LAB E10-N1 — Semantic-worker loss negative control
|
||||
|
||||
Date: 2026-07-22
|
||||
State: completed; negative control accepted
|
||||
Ground truth: no
|
||||
Navigation/safety acceptance: no
|
||||
Publication scope: laboratory negative control only
|
||||
|
||||
## Purpose and answer
|
||||
|
||||
E10-N1 tests a failure property required by the unmanned-system runtime:
|
||||
|
||||
> If the lower-rate semantic worker stops, does the 10 Hz detector/world-state
|
||||
> path continue, does semantic freshness become stale explicitly, and does 3D
|
||||
> fusion refuse to use an expired mask?
|
||||
|
||||
The answer is **yes for the accepted 60-second recorded source-paced control**:
|
||||
|
||||
- semantics was intentionally stopped after exactly 20 completed results;
|
||||
- detector/tracker processed 601/601 frames at 10.010952 FPS with zero drops;
|
||||
- world-state age p95 remained 90.268570 ms;
|
||||
- semantic binding transitioned to 498 `stale` detector states;
|
||||
- 3D fusion stopped after 96 fresh-mask frames and never resumed;
|
||||
- freshness violations: zero;
|
||||
- every negative-control acceptance check passed.
|
||||
|
||||
This proves fail-closed behavior for a controlled semantic-worker stop. It does
|
||||
not yet prove recovery/restart, direct K1 transport or a process/container crash.
|
||||
|
||||
## Input and configuration identity
|
||||
|
||||
The camera, calibration, LiDAR pack, models and 60-second source interval are
|
||||
identical to accepted LAB E10:
|
||||
|
||||
- session: `RAVNOVES00` / `20260720T065719Z_viewer_live`;
|
||||
- source: `sensor.camera.right`, 800x600;
|
||||
- source frames: 1,000–1,600 inclusive;
|
||||
- session time: 135.365857292–195.334857292 seconds;
|
||||
- source span: 59.969 seconds;
|
||||
- calibration slot: `camera_1`;
|
||||
- calibration SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`;
|
||||
- LiDAR pack:
|
||||
`e10-lidar-pack-5da0396d32a27f9d1ca537cc2e8a371d386078d6f0dc71737b78620992af9625`;
|
||||
- detector: YOLOX-S/Triton every frame;
|
||||
- semantics: EoMT/FP16 scheduled every fifth frame;
|
||||
- semantic TTL: 750 ms;
|
||||
- detector queue capacity: 2;
|
||||
- semantic queue capacity: 1.
|
||||
|
||||
The only intentional change is:
|
||||
|
||||
```text
|
||||
mode = semantic-loss-negative-control
|
||||
stop_after_completed_results = 20
|
||||
```
|
||||
|
||||
Profile SHA-256:
|
||||
`eb2b114048336c273e77f3046d1eeac5187d27ec01e830de89ff201fb326c8c8`.
|
||||
|
||||
## Failure injection and expected behavior
|
||||
|
||||
The semantic worker exits cleanly immediately after publishing its twentieth
|
||||
real mask. The source producer and detector consumer remain unchanged. The last
|
||||
semantic result may be reused only until its 750 ms TTL expires.
|
||||
|
||||
After TTL expiration:
|
||||
|
||||
- semantic status must become `stale`;
|
||||
- world-state delivery health becomes degraded where LiDAR exists;
|
||||
- LiDAR association and cuboid generation must not execute with that mask;
|
||||
- raw source scheduling and detector/tracker must continue to the final frame;
|
||||
- the bounded semantic queue may discard scheduled work because its consumer is
|
||||
intentionally absent, but this must not block the detector queue.
|
||||
|
||||
The result uses publication scope
|
||||
`recorded-integrated-semantic-loss-negative-control-only`. Mission Core's E10
|
||||
overlay store explicitly excludes that scope, so the negative control cannot
|
||||
replace the accepted visual E10 result in `RAVNOVES00`.
|
||||
|
||||
## Result
|
||||
|
||||
Result ID:
|
||||
|
||||
`e10-integrated-perception-cf3b763731515120dc2714b6c3d7030496ed75057ce41ca88a1df62c21c4149c`
|
||||
|
||||
### Detector and world-state continuity
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Detector scheduled / processed | 601 / 601 |
|
||||
| Detector drops | 0 |
|
||||
| Detector effective rate | 10.010952 FPS |
|
||||
| Detector queue maximum / capacity | 1 / 2 |
|
||||
| Replay wall / source span | 60.034251 / 59.969 s |
|
||||
| World-state age mean | 63.686918 ms |
|
||||
| World-state age p95 | 90.268570 ms |
|
||||
| World-state age max | 140.979884 ms |
|
||||
|
||||
The main path remained source-paced after semantic loss. Its p95 stayed well
|
||||
inside the frozen 175 ms laboratory budget.
|
||||
|
||||
### Semantic loss and freshness
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Scheduled semantic frames | 121 |
|
||||
| Completed before injected stop | 20 |
|
||||
| Queue-overwritten after stop | 100 |
|
||||
| Final bounded queue depth | 1 |
|
||||
| Unavailable detector states | 5 |
|
||||
| Fresh detector states | 98 |
|
||||
| Stale detector states | 498 |
|
||||
| Fresh coverage | 16.3062% |
|
||||
|
||||
Queue accounting is exact: 20 consumed + 100 overwritten + 1 final bounded
|
||||
entry = 121 scheduled frames. These 100 semantic discards are the expected
|
||||
effect of intentionally removing the consumer; detector drops remained zero.
|
||||
|
||||
### Fail-closed fusion
|
||||
|
||||
| Fusion state | Frames |
|
||||
|---|---:|
|
||||
| `fused` | 96 |
|
||||
| `semantic-stale` | 428 |
|
||||
| `semantic-unavailable` | 2 |
|
||||
| `depth-unavailable-sync-gate` | 75 |
|
||||
|
||||
- accepted cuboid observations before loss became stale: 279;
|
||||
- fused frames after the mask became stale: zero;
|
||||
- fusion freshness violations: zero.
|
||||
|
||||
The 428 `semantic-stale` fusion states are fewer than the 498 stale detector
|
||||
states because the depth synchronization gate takes precedence on 70 of those
|
||||
frames. Neither path performs fusion with an expired semantic mask.
|
||||
|
||||
## Acceptance contract
|
||||
|
||||
| Check | Threshold | Result |
|
||||
|---|---:|---:|
|
||||
| Detector accounting | exact | pass |
|
||||
| Detector rate | >= 9.5 FPS | 10.010952 |
|
||||
| Detector drop fraction | <= 0.5% | 0% |
|
||||
| World-state age p95 | <= 175 ms | 90.268570 ms |
|
||||
| Semantic stop | exactly 20 results | 20 |
|
||||
| Stale detector states | >= 450 | 498 |
|
||||
| Semantic queue accounting | exact | pass |
|
||||
| Fusion with non-fresh mask | zero | zero |
|
||||
| Worker/producer errors | zero | zero |
|
||||
|
||||
All checks passed. `navigation_or_safety_accepted` remains false.
|
||||
|
||||
## GPU, memory and disk
|
||||
|
||||
Across 61 telemetry samples:
|
||||
|
||||
- GPU utilization: 58.72% mean, 90% p95, 93% max;
|
||||
- board memory used: 13,151.64 MiB mean, 13,161 MiB max;
|
||||
- power: 160.05 W mean, 211.02 W p95, 219.41 W max;
|
||||
- temperature: 41.28 C mean, 42 C max;
|
||||
- CUDA peak allocated: 2,107.925 MiB;
|
||||
- CUDA peak reserved: 2,840.0 MiB;
|
||||
- process peak RSS: 2,023.730 MiB.
|
||||
|
||||
The worker orchestrator used only `D:\NDC_MISSIONCORE`, enforced a 360 GiB
|
||||
floor, and completed with approximately 376.88 GiB free after cleanup. Windows
|
||||
C was not used. YOLOX was returned to its previous load state.
|
||||
|
||||
## Producer and artifact identity
|
||||
|
||||
- runner SHA-256:
|
||||
`894e679c91d398b0084fa7894346c827e6a54b01e1841cf505a21be76a6c7188`;
|
||||
- fusion runtime SHA-256:
|
||||
`042136fffbc6e06ef62c6d223d5272ce48e0136651f430ab1d59729b32c4bd90`;
|
||||
- orchestrator SHA-256:
|
||||
`ac0ee42495839773a2de60090ebcb49eee979dc7ee3b8587662a3c59e19a4d03`;
|
||||
- profile SHA-256:
|
||||
`eb2b114048336c273e77f3046d1eeac5187d27ec01e830de89ff201fb326c8c8`;
|
||||
- independent validator/renderer SHA-256:
|
||||
`deb9e9f581b0868c9cf3ecae73b17b3253152c969d46089f11ad364efa6f6804`.
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
|---|---|
|
||||
| `semantic-frames.jsonl` | `ad187a84335cefed40b2d2db7261c16ff053057d49554dc9097e1258a63fa81e` |
|
||||
| `fusion-frames.jsonl` | `dbd8f203c41ed3f9ba0325b9a53227c7d25c66a34733caf0f2d687a18c26b5a6` |
|
||||
| `world-state.jsonl` | `2a4e1e0e1bade8f07b12e880602c24fb4b4f8814b683ab01b407174592bd4230` |
|
||||
| `transient-perception.npz` | `b653d8be9bc2474804268e9aa0a8c7dfdb1b2cacc779b515ef6b112aa9231e89` |
|
||||
| `gpu-telemetry.jsonl` | `07708357da7a5c464db2015f897137deb730762c68e247ed5eeb40cee19ac108` |
|
||||
| `run-report.json` | `06a8170ccdf561c51616050887a0ddd489894e36d22d35f0dd02ebbc700cf9e1` |
|
||||
| `result.json` | `e23821353a3afdd3789b9fee57d58915ca56c002828453662ec30824a878d3f5` |
|
||||
|
||||
Independent validation accepted the result and confirmed its negative-control
|
||||
publication scope. A store-selection regression proved that the UI continues to
|
||||
select the accepted qualification result
|
||||
`e10-integrated-perception-c6e0263...`, not E10-N1. The targeted regression
|
||||
suite now passes 48 tests.
|
||||
|
||||
## Accepted scope
|
||||
|
||||
- controlled semantic-worker stop after real inference results;
|
||||
- detector/tracker continuity at source pace;
|
||||
- explicit unavailable/fresh/stale state transition;
|
||||
- fail-closed LiDAR fusion after TTL expiration;
|
||||
- bounded semantic queue behavior without detector backpressure;
|
||||
- negative-control isolation from the user-facing Rerun result;
|
||||
- D-only storage and disk-floor enforcement.
|
||||
|
||||
## Not accepted
|
||||
|
||||
- automatic semantic-worker restart or state recovery;
|
||||
- hard process/container/GPU failure;
|
||||
- direct K1 live transport;
|
||||
- long-duration operation;
|
||||
- target onboard hardware;
|
||||
- navigation or safety use.
|
||||
|
||||
## Next gate
|
||||
|
||||
The next gate is the full `RAVNOVES00` stability run with the normal accepted E10
|
||||
configuration. It must cover the complete approximately nine-minute camera
|
||||
track, build a matching full-session LiDAR replay pack, preserve bounded queues
|
||||
and memory, and keep the D free-space floor. After that, the recorded source
|
||||
adapter can be replaced with direct K1 transport under the same contracts.
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
# LAB E10 — Integrated source-paced perception and world state
|
||||
|
||||
Date: 2026-07-22 (Europe/Moscow)
|
||||
Run timestamp: 2026-07-21T22:30:03.785Z
|
||||
State: completed; 60-second qualification accepted
|
||||
Ground truth: no
|
||||
Navigation/safety acceptance: no
|
||||
|
||||
## Purpose and answer
|
||||
|
||||
LAB E10 is the first Mission Core experiment that executes the current
|
||||
perception branches as one source-paced pipeline rather than composing separate
|
||||
offline outputs:
|
||||
|
||||
```text
|
||||
recorded K1 camera at original timestamps
|
||||
-> valid fisheye FOV
|
||||
-> YOLOX/Triton detections at ~10 Hz
|
||||
-> ByteTrack-style temporal IDs
|
||||
+ concurrent EoMT semantics at ~2 Hz
|
||||
+ factory KB4 camera/LiDAR projection
|
||||
+ strict recorded LiDAR synchronization gate
|
||||
-> point-supported distance and oriented 3D cuboids
|
||||
-> clearance sectors and transient world state
|
||||
-> Mission Core/Rerun recorded-session overlay
|
||||
```
|
||||
|
||||
The answer is **yes for the accepted recorded 60-second interval on the RTX
|
||||
4090 worker**:
|
||||
|
||||
- detector/tracker: 601/601 frames, 0 drops, 9.996120 FPS;
|
||||
- semantics: 121/121 scheduled frames, 0 drops, 2.012530 FPS;
|
||||
- fresh semantic coverage: 597/601 states, 99.3344%;
|
||||
- LiDAR-fused states: 525;
|
||||
- accepted point-supported cuboids: 760 observations;
|
||||
- world-state age p95: 76.514918 ms;
|
||||
- measured replay wall time: 60.123327 seconds for 59.969 source seconds;
|
||||
- every frozen acceptance check passed.
|
||||
|
||||
This closes the integration gap left by E9. Semantic masks used by LiDAR
|
||||
association were the actual newest in-memory EoMT results from the concurrent
|
||||
worker. E6 fusion and E7-style world-state calculation were executed inside the
|
||||
same critical replay loop. Per-frame masks were not synchronously encoded as
|
||||
PNG during the measurement.
|
||||
|
||||
This result does **not** prove direct live K1 transport, long-duration stability,
|
||||
forest-domain accuracy, target onboard compute performance or safety fitness.
|
||||
|
||||
## Immutable input identity
|
||||
|
||||
- Compute job: `recorded-camera-602ac89026ed12978619801d`
|
||||
- Input SHA-256:
|
||||
`602ac89026ed12978619801d4edea0cae24b5cc3afabd9f7af2858de6505a20e`
|
||||
- Session title: `RAVNOVES00`
|
||||
- Session ID: `20260720T065719Z_viewer_live`
|
||||
- Camera source: `sensor.camera.right`
|
||||
- Resolution: 800x600
|
||||
- Factory calibration slot: `camera_1`
|
||||
- Calibration content SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- Source frame selection: 1,000–1,600 inclusive, 601 frames
|
||||
- Session timeline: 135.365857292–195.334857292 seconds
|
||||
- Timeline SHA-256:
|
||||
`06e3514228b488137b92c303cbca171154ede00368783e32107ed2c39c41baa6`
|
||||
- Source span: 59.969 seconds
|
||||
|
||||
Valid-FOV identity:
|
||||
|
||||
- generation:
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`;
|
||||
- mask SHA-256:
|
||||
`a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`;
|
||||
- valid pixels: 270,606 of 480,000.
|
||||
|
||||
The FOV mask is generated once and content-addressed. Pixels outside the usable
|
||||
fisheye circle are excluded before model processing; no repeated per-frame mask
|
||||
discovery occurs.
|
||||
|
||||
## LiDAR replay pack
|
||||
|
||||
The 60-second LiDAR input is a private immutable replay pack derived from the
|
||||
accepted E6 synchronization evidence:
|
||||
|
||||
- pack ID:
|
||||
`e10-lidar-pack-5da0396d32a27f9d1ca537cc2e8a371d386078d6f0dc71737b78620992af9625`;
|
||||
- 601 camera timeline rows;
|
||||
- 526 LiDAR rows passing the accepted strict synchronization gate;
|
||||
- 1,182,292 total points;
|
||||
- source coordinate system: K1 map frame;
|
||||
- target camera: `sensor.camera.right` / factory `camera_1`;
|
||||
- projection model: KB4, 800x600;
|
||||
- temporal contract: accepted E6 nearest-host-arrival best effort;
|
||||
- `lidar-pack.npz`: 9,629,449 bytes;
|
||||
- pack SHA-256:
|
||||
`72aa73340b20fcfaa21b330ef5b93b975c14a70e2cd16a57752d9952ff05ad9a`;
|
||||
- manifest SHA-256:
|
||||
`189a56660b69b05d39308b8dfa6c5ad8ed8812e870a218188181787cc6ea00ba`.
|
||||
|
||||
The pack is a replay optimization only. It does not change camera/LiDAR
|
||||
calibration, points, timestamps or the E6 synchronization decision.
|
||||
|
||||
## Models and worker runtime
|
||||
|
||||
Detector/tracker branch:
|
||||
|
||||
- official Megvii YOLOX-S ONNX release `0.1.1rc0`;
|
||||
- COCO-80 classes;
|
||||
- model SHA-256:
|
||||
`c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
|
||||
- ONNX Runtime GPU through the existing Triton container;
|
||||
- ByteTrack-style two-stage IoU tracking inherited from E5/E8/E9.
|
||||
|
||||
Semantic branch:
|
||||
|
||||
- `tue-mps/cityscapes_semantic_eomt_large_1024`;
|
||||
- revision: `8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f`;
|
||||
- weights: 1,276,175,488 bytes;
|
||||
- weights SHA-256:
|
||||
`c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`;
|
||||
- batch size one, FP16 autocast;
|
||||
- Cityscapes output mapped to the existing Mission Core 0–15 taxonomy.
|
||||
|
||||
Worker runtime:
|
||||
|
||||
- GPU: NVIDIA GeForce RTX 4090;
|
||||
- container: `nvcr.io/nvidia/tritonserver:26.06-py3`;
|
||||
- Python 3.12.3;
|
||||
- PyTorch 2.13.0+cu130;
|
||||
- Transformers 4.57.6;
|
||||
- NumPy 1.26.4;
|
||||
- SciPy 1.16.3.
|
||||
|
||||
## Frozen scheduling and fusion configuration
|
||||
|
||||
Detector path:
|
||||
|
||||
- every source frame, nominal 10 Hz;
|
||||
- bounded latest-wins queue capacity: 2;
|
||||
- observed maximum queue depth: 1;
|
||||
- overflow drops: 0.
|
||||
|
||||
Semantic path:
|
||||
|
||||
- every fifth source frame, nominal 2 Hz;
|
||||
- bounded latest-wins queue capacity: 1;
|
||||
- TTL: 750 ms;
|
||||
- overflow drops: 0;
|
||||
- states before the first completed mask are explicitly `unavailable`.
|
||||
|
||||
LiDAR association:
|
||||
|
||||
- nearest-depth KB4 projection buffer;
|
||||
- horizontal box inset 5%; top inset 4%; bottom inset 2%;
|
||||
- semantic support required for person, bicycle, motorcycle and vehicle groups;
|
||||
- class-dependent spatial clustering radius: 0.9 m for person/two-wheelers,
|
||||
1.5 m for vehicles;
|
||||
- minimum support points: 3 for person/two-wheelers, 5 for vehicles;
|
||||
- depth-cluster split: max(0.5 m, 6% of depth);
|
||||
- same-group NMS IoU: 0.55;
|
||||
- distance history: 5 frames;
|
||||
- distance innovation gate: max(1.5 m, 25%);
|
||||
- oriented cuboid dimensions use robust p05–p95 surface extents and explicit
|
||||
class-dependent plausibility limits.
|
||||
|
||||
World state:
|
||||
|
||||
- velocity history: 1.0 second;
|
||||
- clearance: 72 azimuth sectors;
|
||||
- considered range: 0.5–30 m;
|
||||
- obstacle height above estimated ground: 0.2–3.0 m;
|
||||
- forward clearance half-angle: 15 degrees.
|
||||
|
||||
## Frozen acceptance contract
|
||||
|
||||
| Check | Threshold | Result |
|
||||
|---|---:|---:|
|
||||
| Detector effective rate | >= 9.5 FPS | 9.996120 FPS |
|
||||
| Detector drop fraction | <= 0.5% | 0% |
|
||||
| Semantic effective rate | >= 1.8 FPS | 2.012530 FPS |
|
||||
| Semantic drop fraction | <= 5% | 0% |
|
||||
| Semantic completion age p95 | <= 400 ms | 179.682194 ms |
|
||||
| Fresh semantic coverage | >= 90% | 99.3344% |
|
||||
| World-state age p95 | <= 175 ms | 76.514918 ms |
|
||||
| LiDAR-fused frames | >= 450 | 525 |
|
||||
| Accepted cuboid observations | >= 100 | 760 |
|
||||
| Failures | zero | zero |
|
||||
|
||||
All checks were frozen before the qualification output and all passed. The
|
||||
result remains explicitly `navigation_or_safety_accepted=false`.
|
||||
|
||||
## Timing result
|
||||
|
||||
### Critical detector/fusion/world-state path
|
||||
|
||||
| Stage | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| World-state age from source deadline | 56.074 ms | 52.017 ms | 76.515 ms | 118.755 ms |
|
||||
| Camera decode | 28.826 ms | 28.332 ms | 33.181 ms | 51.831 ms |
|
||||
| Detector inference path | 23.442 ms | 20.298 ms | 36.127 ms | 48.794 ms |
|
||||
| Detector queue wait | 0.087 ms | 0.066 ms | 0.155 ms | 5.694 ms |
|
||||
| LiDAR projection | 0.411 ms | 0.358 ms | 0.723 ms | 4.258 ms |
|
||||
| Semantic/LiDAR association | 1.649 ms | 1.391 ms | 3.858 ms | 12.513 ms |
|
||||
| Clearance | 0.187 ms | 0.175 ms | 0.394 ms | 0.667 ms |
|
||||
| World-state serialization/calculation | 0.088 ms | 0.076 ms | 0.226 ms | 0.586 ms |
|
||||
|
||||
The critical path stayed below its 175 ms laboratory ceiling without frame
|
||||
drops. Decode plus detection dominates the 10 Hz path; LiDAR projection,
|
||||
association, cuboid generation, clearance and world-state computation consume
|
||||
only a few milliseconds at p95 on this input.
|
||||
|
||||
### Semantic branch
|
||||
|
||||
| Stage | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Completion age from source deadline | 155.067 ms | 150.439 ms | 179.682 ms | 384.761 ms |
|
||||
| Processing after dequeue | 124.748 ms | 122.595 ms | 138.430 ms | 353.011 ms |
|
||||
| EoMT forward | 98.849 ms | 96.101 ms | 112.343 ms | 309.115 ms |
|
||||
| Processor | 9.647 ms | 9.347 ms | 11.958 ms | 31.297 ms |
|
||||
| Host to device | 5.680 ms | 6.063 ms | 8.184 ms | 11.627 ms |
|
||||
| Model postprocess | 7.451 ms | 6.703 ms | 11.166 ms | 12.413 ms |
|
||||
| Valid-FOV fill | 1.927 ms | 1.882 ms | 2.249 ms | 3.075 ms |
|
||||
| Queue wait | 0.145 ms | 0.155 ms | 0.194 ms | 0.308 ms |
|
||||
|
||||
The semantic branch is deliberately asynchronous. A semantic outlier therefore
|
||||
does not block detector admission or raw recording. The newest completed mask
|
||||
is bound only to current/future detector states and only while its TTL remains
|
||||
valid.
|
||||
|
||||
## Fusion and world-state output
|
||||
|
||||
Fusion states across 601 detector rows:
|
||||
|
||||
- `fused`: 525;
|
||||
- `depth-unavailable-sync-gate`: 75;
|
||||
- `semantic-unavailable`: 1.
|
||||
|
||||
The LiDAR pack exposes 526 strict-sync frames. One of those occurred before the
|
||||
first semantic result completed, explaining 525 actual fused states without
|
||||
inventing a mask.
|
||||
|
||||
Cuboid association counts:
|
||||
|
||||
- accepted point-supported cuboid observations: 760;
|
||||
- rejected, fewer than five clustered points: 996;
|
||||
- rejected, no semantic LiDAR support: 574;
|
||||
- rejected, distance innovation: 172;
|
||||
- rejected, fewer than three clustered points: 45;
|
||||
- rejected, implausible cuboid: 14.
|
||||
|
||||
The 760 value is a count of accepted per-frame cuboid observations, not 760
|
||||
unique physical objects. Cuboids bound the visible LiDAR-supported surface. They
|
||||
must not be interpreted as complete object volume or ground truth.
|
||||
|
||||
## GPU, memory and disk
|
||||
|
||||
Across 61 one-second telemetry samples:
|
||||
|
||||
- GPU utilization: 45.57% mean, 61% p95, 65% max;
|
||||
- board memory used: 13,119.30 MiB mean, 13,128 MiB max;
|
||||
- board memory utilization: 19.51% mean, 26% max;
|
||||
- power: 154.70 W mean, 166.75 W p95, 178.45 W max;
|
||||
- temperature: 43.87 C mean, 46 C max;
|
||||
- EoMT process CUDA peak allocated: 2,107.925 MiB;
|
||||
- EoMT process CUDA peak reserved: 2,840.0 MiB;
|
||||
- process peak RSS: 2,022.535 MiB.
|
||||
|
||||
This shows usable compute headroom on the laboratory RTX 4090 for this frozen
|
||||
configuration. It says nothing yet about the final onboard GPU/accelerator.
|
||||
That hardware must receive its own experiment and budgets.
|
||||
|
||||
Task-controlled worker storage remained under `D:\NDC_MISSIONCORE`:
|
||||
|
||||
| Disk point | Free bytes | Approx. free GiB |
|
||||
|---|---:|---:|
|
||||
| Before measured replay | 404,946,915,328 | 377.136 |
|
||||
| After measured replay | 404,933,578,752 | 377.124 |
|
||||
| Enforced floor | 386,547,056,640 | 360.000 |
|
||||
|
||||
Temporary decoded frames were cleaned from the exact run-owned D path. The
|
||||
YOLOX model state was restored after the run. Triton, Frigate and Ollama were
|
||||
left running. Windows C was not used or modified.
|
||||
|
||||
## Result artifacts and integrity
|
||||
|
||||
Published result:
|
||||
|
||||
`e10-integrated-perception-c6e0263b9e18be7e4f447597065b5ff93b8511463e8f453800f98a61f25c37df`
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `semantic-frames.jsonl` | 55,025 | `16cb53c7df8491b6620d0483f5004953d0aa54e83198bb101ae357c06e34be89` |
|
||||
| `fusion-frames.jsonl` | 1,418,427 | `1b28baf9dfc6c8a793f60233f5ffd03a3e40f63a4dfac7a97bff2813f5bd967d` |
|
||||
| `world-state.jsonl` | 1,524,066 | `9bdab9c1749fca98797ab428bd94f3e1b5a80b7977f013f323283ce9559040ee` |
|
||||
| `transient-perception.npz` | 1,106,484 | `7e0d0761584f4700e55caf31965d53de1c13b4f8a17c7cfbd2279af4c5f4cabd` |
|
||||
| `gpu-telemetry.jsonl` | 13,704 | `e036cdd35addc96152c3c534e75e249ac25b18b588cecabb30a8729d36d80f3c` |
|
||||
| `run-report.json` | 13,563 | `a053d629ea1b48e4a039d7277e0753108926e6efa0000971bc89008eac66ec26` |
|
||||
| `result.json` | immutable manifest | `48ac8865ff629b00e56ca90cb4a3a68b161f06275465a86ae7973808f8472c2b` |
|
||||
|
||||
Accepted producer identities:
|
||||
|
||||
- runner SHA-256:
|
||||
`3f667e291f18bcccd58d0ced391e23e0cca05ba3cdeb0da50b0f2fb814c4ff70`;
|
||||
- fusion runtime SHA-256:
|
||||
`4d4d9d7fcf8f3b86ac84228902df1948aafc2f48643c53f77250e77bf9854d5d`;
|
||||
- profile SHA-256:
|
||||
`42e2a49945e65daf86e7891794e30ef2c5182b71d073c76ae764c03ea2501589`;
|
||||
- accepted run orchestrator SHA-256:
|
||||
`8bc939e9a5e94707cd30bf7d948fed893087c03d3345e31c20c853b237ecde76`.
|
||||
|
||||
After acceptance, only the orchestrator's human-readable free-space line was
|
||||
changed from pipeline output to host output so future variable assignment does
|
||||
not capture the label. Current orchestrator SHA-256 is
|
||||
`ac0ee42495839773a2de60090ebcb49eee979dc7ee3b8587662a3c59e19a4d03`.
|
||||
This post-run correction does not alter or relabel the accepted E10 result.
|
||||
|
||||
The local E10 Python sources were subsequently normalized by the repository's
|
||||
Ruff formatter and passed the same 47-test regression suite. The accepted
|
||||
worker staging directory remains the immutable source of the producer hashes
|
||||
above. Current local source hashes for the next run are:
|
||||
|
||||
- fusion runtime:
|
||||
`042136fffbc6e06ef62c6d223d5272ce48e0136651f430ab1d59729b32c4bd90`;
|
||||
- LiDAR-pack builder:
|
||||
`e75875342f11a0573eb5eef106f10ff9545debf6c012e717c1df281d479b746b`;
|
||||
- worker runner:
|
||||
`9e434a82d370d1ecf079d76cec5c7a4cf955ca631651862eacb8572b94e33c8b`;
|
||||
- Mission Core validator/renderer:
|
||||
`260a03c623bcab15c6dafd63caa36514e9e35a40e0e6bd4d6551eb3db832c058`.
|
||||
|
||||
Durable local evidence root:
|
||||
|
||||
```text
|
||||
.runtime/compute-experiments/e10/worker-results/
|
||||
```
|
||||
|
||||
Worker result root:
|
||||
|
||||
```text
|
||||
D:\NDC_MISSIONCORE\runtime\derived\
|
||||
e10-integrated-perception-c6e0263b9e18be7e4f447597065b5ff93b8511463e8f453800f98a61f25c37df
|
||||
```
|
||||
|
||||
## Independent validation and regression checks
|
||||
|
||||
The independent E10 validator confirmed:
|
||||
|
||||
- content-addressed result identity and artifact hashes;
|
||||
- exact job/session/source/calibration/model/profile/runner binding;
|
||||
- detector and semantic queue accounting;
|
||||
- ordered source timestamps and semantic stride;
|
||||
- semantic binding never comes from a future source frame;
|
||||
- unavailable states contain no invented perception payload;
|
||||
- LiDAR pack identity, size and hash;
|
||||
- every frozen acceptance check.
|
||||
|
||||
The standalone E10 fusion runtime was also run against the accepted E6
|
||||
precomputed E4/E5 inputs. It reproduced E6 exactly: 526 fused frames and 918
|
||||
accepted cuboid observations. This is the control that the E10 integration did
|
||||
not silently change the established E6 geometry algorithm.
|
||||
|
||||
Targeted automated tests passed: 47 tests across E10, compute-result discovery,
|
||||
session API, E9, and E6. Direct Rerun rendering produced a valid 58,342,680-byte
|
||||
`RRF2` stream. The Mission Core perception endpoint returned HTTP 200 with a
|
||||
58,342,936-byte `RRF2` response.
|
||||
|
||||
## Mission Core / Rerun publication
|
||||
|
||||
Mission Core now discovers the newest accepted E10 result for the active
|
||||
recording before falling back to E6 or legacy 2D results. The generated Rerun
|
||||
stream contains:
|
||||
|
||||
- camera frames;
|
||||
- semantic masks;
|
||||
- 2D tracked detections;
|
||||
- raw map-frame point cloud;
|
||||
- LiDAR support points;
|
||||
- oriented point-supported 3D cuboids;
|
||||
- range and world-state evidence.
|
||||
|
||||
Visual acceptance was completed in the local Control Station:
|
||||
|
||||
1. `Наблюдение` -> `Пространственная сцена`;
|
||||
2. saved session `RAVNOVES00`;
|
||||
3. three active sources loaded;
|
||||
4. original camera and `Сегментация · камера right` played together on the
|
||||
common session timeline;
|
||||
5. real point cloud and trajectory remained visible behind the camera panels;
|
||||
6. playback time advanced normally, proving the result is not a single image.
|
||||
|
||||
The E10 perception overlay covers only its qualified 60-second source interval.
|
||||
The underlying saved session remains the full 8:55.718 recording.
|
||||
|
||||
## Accepted scope
|
||||
|
||||
- actual concurrent YOLOX/Triton and EoMT/PyTorch execution;
|
||||
- actual current in-memory semantic mask consumed by LiDAR association;
|
||||
- source-paced detector/tracker near 10 Hz and semantics near 2 Hz;
|
||||
- bounded latest-wins queues with zero measured drops;
|
||||
- factory KB4 projection using XGRIDS calibration;
|
||||
- point-supported distance and oriented 3D cuboids;
|
||||
- clearance and transient world-state publication in the critical loop;
|
||||
- 60-second latency, GPU, memory and disk evidence;
|
||||
- playback in Mission Core/Rerun for the recorded session;
|
||||
- D-only worker storage and restored service/model state.
|
||||
|
||||
## Not accepted
|
||||
|
||||
- direct K1 MQTT/camera transport into the perception scheduler;
|
||||
- the full 8:55.718 session or multi-hour continuous operation;
|
||||
- hardware-clock camera/LiDAR synchronization proof;
|
||||
- failure behavior after semantic-worker loss;
|
||||
- forest/off-road model quality or class coverage;
|
||||
- final onboard compute rate, thermals or power;
|
||||
- complete-object 3D geometry;
|
||||
- navigation or safety use.
|
||||
|
||||
## Next gates
|
||||
|
||||
The next work is aimed at the unmanned-system operating contour, not cosmetic
|
||||
offline quality:
|
||||
|
||||
1. **Semantic-loss negative control.** Kill or stall the semantic branch during
|
||||
source-paced replay and prove detector/raw recording continue, freshness
|
||||
becomes stale/unavailable, and no false fresh mask is published.
|
||||
2. **Full RAVNOVES00 stability run.** Build a full-session immutable LiDAR pack,
|
||||
run the same frozen pipeline for approximately nine minutes, verify bounded
|
||||
memory/queues, zero recording interference and D free-space floor.
|
||||
3. **Direct K1 transport.** Replace the recorded camera/LiDAR source adapter
|
||||
with the existing live K1 ingestion while preserving the accepted scheduler,
|
||||
freshness and world-state contracts.
|
||||
4. **Target onboard worker qualification.** Repeat the same gates on the actual
|
||||
deployable compute hardware; optimize models only against explicit rate,
|
||||
latency, thermal and power budgets.
|
||||
5. **Domain quality branch.** In parallel with runtime work, create forest and
|
||||
off-road evaluation data for classes, traversability and geometry. This must
|
||||
not block proving the live transport and scheduling contour.
|
||||
|
||||
Item 1 was subsequently completed and accepted as
|
||||
`LAB_E10_N1_REPORT_2026-07-22.md`: detector continuity remained 10.010952 FPS
|
||||
with zero drops, 498 states became explicitly stale, and fusion recorded zero
|
||||
freshness violations after the controlled semantic stop. The immediate gate is
|
||||
therefore the full-session run. No model upgrade or UI polish is justified
|
||||
before long-duration boundedness is measured.
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
# LAB E11 — Full-session integrated perception stability qualification
|
||||
|
||||
Date: 2026-07-22
|
||||
Accepted result creation time: 2026-07-22T06:27:51.941Z
|
||||
Laboratory status: **accepted**
|
||||
Navigation or safety status: **not accepted**
|
||||
|
||||
## Objective
|
||||
|
||||
LAB E11 tested whether the integrated Mission Core perception contour proven on
|
||||
the 60-second LAB E10 interval remains bounded and source-paced across the
|
||||
complete recorded right-camera epoch from `RAVNOVES00`.
|
||||
|
||||
The qualification retained the intended onboard architecture:
|
||||
|
||||
```text
|
||||
recorded camera at original source timestamps
|
||||
-> bounded latest-wins detector queue
|
||||
-> YOLOX-S + ByteTrack-compatible tracking at camera rate
|
||||
-> bounded sampled semantic queue
|
||||
-> EoMT Cityscapes semantics at 1/5 camera rate
|
||||
-> factory camera_1 KB4 camera/LiDAR projection
|
||||
-> visible LiDAR-supported 3D cuboids and distance
|
||||
-> clearance sectors and timestamped world state
|
||||
```
|
||||
|
||||
This was not an offline maximum-quality render. The replay speed was exactly
|
||||
`1.0`, queues were bounded, and acceptance required the worker to keep up with
|
||||
the recorded source without detector loss.
|
||||
|
||||
## Result summary
|
||||
|
||||
Accepted immutable result:
|
||||
|
||||
`e10-integrated-perception-7613b09021455ddec941144224142f7879e3c6ecac5b2370ea8a7d4cb01918ba`
|
||||
|
||||
Worker result root:
|
||||
|
||||
`D:\NDC_MISSIONCORE\runtime\derived\e10-integrated-perception-7613b09021455ddec941144224142f7879e3c6ecac5b2370ea8a7d4cb01918ba`
|
||||
|
||||
Local verified mirror:
|
||||
|
||||
`.runtime/compute-experiments/e10/worker-results/e10-integrated-perception-7613b09021455ddec941144224142f7879e3c6ecac5b2370ea8a7d4cb01918ba`
|
||||
|
||||
| Measure | Result | Frozen gate | State |
|
||||
|---|---:|---:|---|
|
||||
| Source frames | 4,489/4,489 | exactly 4,489 | pass |
|
||||
| Detector drops | 0 | 0 | pass |
|
||||
| Detector effective rate | 10.004402 FPS | >=9.5 FPS | pass |
|
||||
| Semantic results | 898/898 scheduled | accounting exact | pass |
|
||||
| Semantic drops | 0 | <=5% | pass |
|
||||
| Semantic effective rate | 2.001326 FPS | >=1.8 FPS | pass |
|
||||
| Fresh semantic coverage | 99.754956% | >=90% | pass |
|
||||
| Semantic completion age p95 | 267.211864 ms | <=400 ms | pass |
|
||||
| World-state age p95 | 102.714712 ms | <=175 ms | pass |
|
||||
| LiDAR-fused frames | 3,917 | >=3,500 | pass |
|
||||
| Accepted cuboid observations | 6,157 | >=500 | pass |
|
||||
| Inference/producer failures | 0 | 0 | pass |
|
||||
| Fusion with non-fresh semantics | 0 | 0 | pass |
|
||||
|
||||
The complete 448.623-second camera interval was replayed in 448.702 seconds.
|
||||
This is source-paced operation, not an accelerated batch claim.
|
||||
|
||||
## Frozen inputs
|
||||
|
||||
### Recorded source
|
||||
|
||||
- UI session alias: `RAVNOVES00`;
|
||||
- session ID: `20260720T065719Z_viewer_live`;
|
||||
- compute job: `recorded-camera-602ac89026ed12978619801d`;
|
||||
- job input SHA-256:
|
||||
`602ac89026ed12978619801d4edea0cae24b5cc3afabd9f7af2858de6505a20e`;
|
||||
- source: `sensor.camera.right`;
|
||||
- codec epoch: `1`;
|
||||
- resolution: 800x600;
|
||||
- source frame indices: 0–4,488 inclusive;
|
||||
- camera timeline: 35.421857292–484.044857292 seconds;
|
||||
- timeline SHA-256:
|
||||
`f91dad0a3cc998f4250be794b48f437943b3c60edccbe7e5ceed59030854a807`;
|
||||
- job payload: 363,235,615 bytes;
|
||||
- raw MQTT evidence: 133,572,722 bytes.
|
||||
|
||||
### Factory calibration
|
||||
|
||||
- slot: `camera_1`;
|
||||
- content identity SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`;
|
||||
- projection: factory KB4 intrinsics plus camera/LiDAR extrinsic;
|
||||
- LiDAR coordinate source: K1 map frame.
|
||||
|
||||
No calibration was fitted or modified during LAB E11.
|
||||
|
||||
### Full LiDAR replay pack
|
||||
|
||||
Immutable pack:
|
||||
|
||||
`e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b`
|
||||
|
||||
- frames: 4,489;
|
||||
- frames with accepted LiDAR and pose: 3,928;
|
||||
- strict-sync coverage: 87.502785%;
|
||||
- point observations: 9,207,270;
|
||||
- compressed payload: 72,996,000 bytes;
|
||||
- payload SHA-256:
|
||||
`0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944`;
|
||||
- builder SHA-256:
|
||||
`bc81a1aa8759a750480745a52af34f4c700261e8bfbf5d615e8c6e5fa96bb69c`;
|
||||
- accepted E6 temporal-profile SHA-256:
|
||||
`b8d3a6f043fde54ffd32ea05bfe580e2be7aace1a23cdbf7e266eb3325d147b2`;
|
||||
- accepted E6 result:
|
||||
`e6-fusion-b4e4226674a66f6196c033785eb307c255a7bdd8493ef9809dfb1d6e5bd68eaa`.
|
||||
|
||||
The pack uses the unchanged E6 temporal policy:
|
||||
|
||||
- clock source: recorded host monotonic arrival;
|
||||
- binding: nearest host-arrival best effort;
|
||||
- maximum camera/LiDAR delta: 100 ms;
|
||||
- maximum point/pose delta: 100 ms.
|
||||
|
||||
The pack is a replay optimization and immutable input. It does not improve or
|
||||
reinterpret synchronization accuracy.
|
||||
|
||||
## Frozen software configuration
|
||||
|
||||
### Integrated scheduling profile
|
||||
|
||||
Profile: `e10_full_session_profile.json`
|
||||
SHA-256: `73dd8472b59c9ad48b781600c89eadb0aab12cb6ef59c4572ce5a8e17dcd4472`
|
||||
|
||||
- mode: `full-session-qualification`;
|
||||
- replay speed: 1.0;
|
||||
- detector queue: latest-wins, capacity 2;
|
||||
- semantic queue: latest-wins, capacity 1;
|
||||
- semantic scheduling: every fifth camera frame;
|
||||
- semantic TTL: 750 ms;
|
||||
- detector loss gate: exactly zero;
|
||||
- required source selection: frames 0–4,488 and at least 448 seconds.
|
||||
|
||||
### Producer identities
|
||||
|
||||
| Component | SHA-256 |
|
||||
|---|---|
|
||||
| Integrated runner | `5548cc607f245cd1c01c85d6b821fdf5b91e512ee9b2f8ad6862175c93b7628c` |
|
||||
| Fusion runtime | `844a3a3ecc56313be2f96a6b861abd8f55100ec7427a72379e52a55fbd4c5749` |
|
||||
| PowerShell orchestrator | `ac0ee42495839773a2de60090ebcb49eee979dc7ee3b8587662a3c59e19a4d03` |
|
||||
| Detector profile | `819bdfb0da521d187ec9ce3d3039a0ff75daa0ab473fb3d8e5fa92db9c1ddca1` |
|
||||
| Semantic profile | `ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875` |
|
||||
|
||||
Worker runner generation:
|
||||
|
||||
`D:\NDC_MISSIONCORE\runtime\derived\e10-runner-5548cc607f24-ac0ee4249583`
|
||||
|
||||
### Models
|
||||
|
||||
Detector:
|
||||
|
||||
- YOLOX-S, COCO-80;
|
||||
- ONNX SHA-256:
|
||||
`c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
|
||||
- Triton config SHA-256:
|
||||
`5795c737a7935a655961b069e8404d336d891f9762fb6dffb93956a076479604`.
|
||||
|
||||
Semantic model:
|
||||
|
||||
- `tue-mps/cityscapes_semantic_eomt_large_1024`;
|
||||
- revision `8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f`;
|
||||
- `model.safetensors` SHA-256:
|
||||
`c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`;
|
||||
- precision: FP16 autocast;
|
||||
- batch size: 1.
|
||||
|
||||
Valid-FOV mask:
|
||||
|
||||
- generation:
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`;
|
||||
- mask SHA-256:
|
||||
`a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`;
|
||||
- admitted pixels: 270,606/480,000.
|
||||
|
||||
## Worker and resource envelope
|
||||
|
||||
- GPU: NVIDIA GeForce RTX 4090;
|
||||
- container: `nvcr.io/nvidia/tritonserver:26.06-py3`;
|
||||
- Python: 3.12.3;
|
||||
- PyTorch: 2.13.0+cu130;
|
||||
- Transformers: 4.57.6;
|
||||
- NumPy: 1.26.4;
|
||||
- SciPy: 1.16.3.
|
||||
|
||||
GPU telemetry, 449 one-second samples:
|
||||
|
||||
| Measure | Mean | p95 | Max |
|
||||
|---|---:|---:|---:|
|
||||
| GPU utilization | 67.70% | 92% | 96% |
|
||||
| Board GPU memory used | 13,151.48 MiB | 13,161 MiB | 13,161 MiB |
|
||||
| GPU-memory controller utilization | 16.40% | 33% | 34% |
|
||||
| Power | 207.34 W | 215.51 W | 229.74 W |
|
||||
| Temperature | 48.46 C | 53 C | 54 C |
|
||||
|
||||
Process and CUDA measurements:
|
||||
|
||||
- process peak RSS: 2,944.93 MiB;
|
||||
- CUDA peak allocated by the runner: 2,107.93 MiB;
|
||||
- CUDA peak reserved by the runner: 2,840 MiB.
|
||||
|
||||
The board-memory figure includes the existing Triton/model context and is not
|
||||
equivalent to runner-only allocation.
|
||||
|
||||
## Latency detail
|
||||
|
||||
| Stage | Mean | p95 | Max |
|
||||
|---|---:|---:|---:|
|
||||
| Frame decode | 34.198 ms | 47.488 ms | 84.767 ms |
|
||||
| Detector | 32.099 ms | 47.759 ms | 70.328 ms |
|
||||
| Detector queue wait | 0.230 ms | 0.202 ms | 33.917 ms |
|
||||
| LiDAR projection | 0.491 ms | 0.940 ms | 7.396 ms |
|
||||
| Object association | 2.176 ms | 5.795 ms | 20.093 ms |
|
||||
| Clearance | 0.236 ms | 0.577 ms | 4.740 ms |
|
||||
| World-state construction | 0.117 ms | 0.333 ms | 4.562 ms |
|
||||
| End-to-end world-state age | 71.634 ms | 102.715 ms | 189.403 ms |
|
||||
|
||||
Semantic inference:
|
||||
|
||||
| Stage | Mean | p95 | Max |
|
||||
|---|---:|---:|---:|
|
||||
| Processing | 191.693 ms | 209.808 ms | 448.629 ms |
|
||||
| Forward | 156.680 ms | 170.785 ms | 414.754 ms |
|
||||
| Completion age | 228.427 ms | 267.212 ms | 477.220 ms |
|
||||
| Semantic queue wait | 0.186 ms | 0.279 ms | 4.548 ms |
|
||||
|
||||
The acceptance contract uses p95 rather than the isolated maximum. Five startup
|
||||
frames had no semantic state and six later frames observed a stale state; no 3D
|
||||
fusion was asserted on any non-fresh semantic state.
|
||||
|
||||
## Fusion detail
|
||||
|
||||
- LiDAR available: 3,928 frames;
|
||||
- fused with fresh semantics: 3,917 frames;
|
||||
- fused share of LiDAR-available frames: 99.719959%;
|
||||
- depth unavailable under the frozen sync gate: 561 frames;
|
||||
- semantic unavailable: 5 frames;
|
||||
- semantic stale: 6 frames;
|
||||
- accepted cuboid observations: 6,157;
|
||||
- fusion freshness violations: 0.
|
||||
|
||||
Rejections are expected diagnostic outcomes:
|
||||
|
||||
| Reason | Count |
|
||||
|---|---:|
|
||||
| Insufficient 5-point support | 8,268 |
|
||||
| No semantic LiDAR support | 4,892 |
|
||||
| Distance innovation gate | 1,045 |
|
||||
| Insufficient 3-point support | 142 |
|
||||
| Implausible cuboid extent | 24 |
|
||||
|
||||
These counts describe per-frame object observations, not unique physical
|
||||
objects.
|
||||
|
||||
## Disk safety and storage behavior
|
||||
|
||||
The worker orchestrator was confined to `D:\NDC_MISSIONCORE`. Drive C was not
|
||||
read or written by this task.
|
||||
|
||||
- configured D free-space floor: 360 GiB;
|
||||
- preflight free space: 376.506 GiB;
|
||||
- calculated additional reserve requirement: 11.834 GiB;
|
||||
- after full stream reconstruction: 376.127 GiB;
|
||||
- after 4,489 PNG extraction: 373.578 GiB;
|
||||
- after publication, before temporary cleanup: 373.347 GiB;
|
||||
- after cleanup and model-state restoration: 376.223 GiB.
|
||||
|
||||
Temporary frames and the reconstructed camera stream were deleted by the
|
||||
orchestrator. No raw recording, prior result, model, cache, or accepted runner
|
||||
generation was deleted.
|
||||
|
||||
Known orchestrator timings:
|
||||
|
||||
- frame preparation: 31.425 seconds;
|
||||
- source-paced replay: 448.702 seconds;
|
||||
- total orchestrator wall time, including model validation/loading and cleanup:
|
||||
600.623 seconds.
|
||||
|
||||
## Published artifacts
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `semantic-frames.jsonl` | 404,380 | `49b1a859924b26ab5f550ca3c6d6ec907c32c0ef01870c270c33ef3eb103db53` |
|
||||
| `fusion-frames.jsonl` | 11,322,702 | `67ee78bc1385e5733ff33106fb7d257825ce297f69356af763c2598a531e8f3c` |
|
||||
| `world-state.jsonl` | 11,703,966 | `d4772484dd0740ba009a947114d4c6bde7b5fc2ffbe55ddf7a341cf9aa11e5a5` |
|
||||
| `transient-perception.npz` | 8,538,476 | `e53f0aea9b94704cd95c898b4b6f9c21da4fdad864373889213a9c1d66080ccf` |
|
||||
| `gpu-telemetry.jsonl` | 100,714 | `2fd0d8f9497f5cff06a3035b9f251c780a0f4fcccf14d3a89f49a18584f43e92` |
|
||||
| `run-report.json` | 14,076 | `3f0577b0dc8b55b38b8528c8f081be71af856327485f088f0ab0305e5a731ab1` |
|
||||
|
||||
Publication scope is
|
||||
`recorded-integrated-realtime-qualification-only`. The accepted result is
|
||||
eligible to become the newest recorded integrated overlay for this session.
|
||||
|
||||
## Verification
|
||||
|
||||
The copied result was independently validated on the Mission Core host using
|
||||
`validate_integrated_perception_result`. Validation rechecked:
|
||||
|
||||
- compute-job identity;
|
||||
- result and configuration identities;
|
||||
- full contiguous source selection;
|
||||
- LiDAR-pack identity and calibration binding;
|
||||
- artifact sizes and SHA-256 values;
|
||||
- 4,489 fusion rows and 4,489 world-state rows;
|
||||
- semantic timeline ordering;
|
||||
- NPZ array shapes and offsets;
|
||||
- report/result acceptance consistency.
|
||||
|
||||
The focused automated suite completed with 24 passing tests, including the E10,
|
||||
E8, E9, live-perception and qualification validators.
|
||||
|
||||
## Controlled preflight rejection
|
||||
|
||||
An initial invocation passed the base E5 detector profile where the integrated
|
||||
runner requires the E8 real-time tracking wrapper. The schema guard rejected it
|
||||
during preflight with `LAB E8 profile schema changed`.
|
||||
|
||||
That invocation did not extract frames, start replay, publish a result, or alter
|
||||
model state. The accepted invocation changed only the detector-profile argument
|
||||
to the already frozen E8 profile. This rejected preflight is retained here so
|
||||
the laboratory history is complete.
|
||||
|
||||
## What LAB E11 proves
|
||||
|
||||
LAB E11 proves that the current RTX 4090 worker and bounded scheduling contour
|
||||
can replay the complete recorded `RAVNOVES00` right-camera epoch at source rate
|
||||
while producing:
|
||||
|
||||
- 10 Hz 2D detection/tracking;
|
||||
- approximately 2 Hz semantic state;
|
||||
- factory-calibrated LiDAR-supported distance and visible-surface cuboids;
|
||||
- timestamped world state and clearance;
|
||||
- bounded queues with no detector or semantic loss in this run.
|
||||
|
||||
It also shows that the one-minute LAB E10 result was not a short-interval
|
||||
artifact: source-paced behavior remained stable for the full 448.623 seconds.
|
||||
|
||||
## What LAB E11 does not prove
|
||||
|
||||
- direct live K1 camera/MQTT ingestion into this scheduler;
|
||||
- hardware-clock camera/LiDAR synchronization;
|
||||
- forest-domain accuracy or robustness;
|
||||
- safety or navigation fitness;
|
||||
- complete amodal 3D object dimensions;
|
||||
- multi-camera fusion;
|
||||
- performance on weaker onboard compute hardware.
|
||||
|
||||
YOLOX COCO and EoMT Cityscapes are generic models. Cuboids describe the visible
|
||||
LiDAR-supported surface selected by the current association policy, not a
|
||||
ground-truth full object volume.
|
||||
|
||||
## Next gate
|
||||
|
||||
The next priority is not another offline quality-only pass. It is a transport
|
||||
integration gate:
|
||||
|
||||
1. feed the same bounded scheduler from direct K1 camera and LiDAR/MQTT sources;
|
||||
2. keep the latest-wins queues and freshness rules unchanged;
|
||||
3. expose live world-state age, drop counters and semantic freshness;
|
||||
4. qualify a controlled near-real-time live interval on this same worker;
|
||||
5. only then compare lower-power onboard targets or more domain-specific models.
|
||||
|
||||
The full-session recorded overlay may be rendered on demand for visual QA, but
|
||||
its generation is presentation work and is not required to accept the compute
|
||||
stability result.
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
# LAB E12 — Bounded shadow transport to the external perception worker
|
||||
|
||||
Date: 2026-07-22
|
||||
State: near-live transport qualification accepted; physical K1 live run pending
|
||||
Ground truth: no
|
||||
Navigation/safety acceptance: no
|
||||
Publication scope: diagnostic shadow transport only
|
||||
|
||||
## Purpose and answer
|
||||
|
||||
LAB E12 asks whether Mission Core can carry the already proven camera,
|
||||
LiDAR and pose inputs to the external RTX worker at source rate without giving
|
||||
that worker a second connection to K1, without allowing it to back-pressure the
|
||||
raw recording, and without granting it any command authority.
|
||||
|
||||
The answer is **yes for a 15-second source-paced RAVNOVES00 near-live run**:
|
||||
|
||||
- Mission Core admitted and the Windows worker verified 446/446 ordered events;
|
||||
- the stream contained 151 camera frames, one camera init, 142 LiDAR frames,
|
||||
150 poses and two lifecycle records;
|
||||
- ingress sequence gaps, payload-integrity failures, queue overflow and oversize
|
||||
rejection were all zero in the nominal run;
|
||||
- the worker completed in 15.073 seconds for a 15-second source interval;
|
||||
- the worker connected only to an authenticated Mission Core endpoint through
|
||||
an SSH reverse tunnel and had no K1 address or K1 transport;
|
||||
- both sides declared `commands_enabled=false` and
|
||||
`navigation_or_safety_accepted=false`.
|
||||
|
||||
The physical K1 run was not attempted because the unit was not present in the
|
||||
BLE scan and the only unclassified LAN neighbour rejected both MQTT and RTSP.
|
||||
No subnet scan or guessed device address was used. The exact same ingress is
|
||||
already attached to the physical acquisition path and will receive live events
|
||||
when K1 is next awake.
|
||||
|
||||
## Architecture under test
|
||||
|
||||
```text
|
||||
K1 camera RTSP ──> Mission Core FFmpeg ──> durable fMP4 segment commit
|
||||
│
|
||||
└─> camera latest-wins queue (2)
|
||||
|
||||
K1 MQTT ──> durable raw K1MQTT commit ──> reviewed live callback
|
||||
├─> LiDAR latest-wins queue (2)
|
||||
└─> pose bounded queue (16)
|
||||
|
||||
separate modality queues ──> one exclusive localhost WebSocket consumer
|
||||
──> bearer authentication ──> SSH reverse tunnel
|
||||
──> read-only Docker probe on RTX worker D:
|
||||
```
|
||||
|
||||
The camera observer is invoked only after `CameraArchiveWriter.append()` has
|
||||
atomically published and fsynced the fragment, its index entry and its crash
|
||||
checkpoint. The MQTT observer is invoked only by `capture_mqtt`'s
|
||||
`on_message_recorded` callback after the raw frame has been committed. Derived
|
||||
perception therefore remains expendable: it cannot become the source of record.
|
||||
|
||||
The ingress is process-local and bounded by modality:
|
||||
|
||||
| Queue | Capacity | Maximum payload |
|
||||
|---|---:|---:|
|
||||
| control | 4 | 16 KiB |
|
||||
| camera init | 1 | 1 MiB |
|
||||
| camera frame | 2 | 1 MiB |
|
||||
| LiDAR | 2 | 2 MiB |
|
||||
| pose | 16 | 2 MiB |
|
||||
|
||||
Camera bursts cannot evict pose or LiDAR observations. A full queue drops its
|
||||
oldest derived item and retains the latest work; it never waits on the worker.
|
||||
Only one authenticated worker consumer may hold the stream lease.
|
||||
|
||||
## Nominal qualification input
|
||||
|
||||
- canonical session: `20260720T065719Z_viewer_live` / `RAVNOVES00`;
|
||||
- camera source: `sensor.camera.right`, codec epoch 1, 800x600 H.264 fMP4;
|
||||
- source interval: the first 15 seconds of the canonical camera epoch;
|
||||
- MQTT input: the matching canonical raw K1MQTT arrival interval;
|
||||
- source pacing: 1.0x recorded host-arrival time;
|
||||
- camera index SHA-256:
|
||||
`e029815a60ad9fbfedb6169142c7449df2b119a51d1ce001f08806e04eb0be14`;
|
||||
- raw MQTT SHA-256:
|
||||
`70da0edad5cbf0e89b6e2355c0aac294f8c36a67249cd33b8918353fd88c83af`.
|
||||
|
||||
This replay is a transport qualification, not a synthetic message generator.
|
||||
Every camera segment was rechecked against its durable length and SHA-256 index
|
||||
record. K1MQTT framing and aligned timing metadata were read through the
|
||||
existing reviewed replay parser.
|
||||
|
||||
## Nominal result
|
||||
|
||||
### Event accounting
|
||||
|
||||
| Modality | Selected | Edge admitted | Worker verified | Payload bytes |
|
||||
|---|---:|---:|---:|---:|
|
||||
| camera init | 1 | 1 | 1 | 763 |
|
||||
| camera frame | 151 | 151 | 151 | 11,583,362 |
|
||||
| LiDAR | 142 | 142 | 142 | 3,674,244 |
|
||||
| pose | 150 | 150 | 150 | 25,346 |
|
||||
| control | 2 | 2 | 2 | 48 |
|
||||
| **Total** | **446** | **446** | **446** | **15,283,763** |
|
||||
|
||||
- worker first/last ingress sequence: 1/446;
|
||||
- sequence gaps: 0;
|
||||
- per-event payload digest mismatches: 0;
|
||||
- session-end observed: yes;
|
||||
- worker wall time: 15.072754 s;
|
||||
- edge publication wall time including orderly disconnect: 15.698036 s.
|
||||
|
||||
### Queue behaviour
|
||||
|
||||
| Queue | Maximum depth / capacity | Consumed | Dropped | Oversize rejected | Final depth |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| control | 1 / 4 | 2 | 0 | 0 | 0 |
|
||||
| camera init | 1 / 1 | 1 | 0 | 0 | 0 |
|
||||
| camera frame | 2 / 2 | 151 | 0 | 0 | 0 |
|
||||
| LiDAR | 2 / 2 | 142 | 0 | 0 | 0 |
|
||||
| pose | 2 / 16 | 150 | 0 | 0 | 0 |
|
||||
|
||||
The camera and LiDAR queues reached their configured maximum capacity but did
|
||||
not overflow: the tunnel/worker consumer drained them within source rate.
|
||||
|
||||
## Worker-disconnect negative control
|
||||
|
||||
A separate four-second source-paced run intentionally removed the consumer
|
||||
after approximately one second. The source still admitted its complete selected
|
||||
interval:
|
||||
|
||||
- camera frames: 41/41 admitted;
|
||||
- LiDAR: 38/38 admitted;
|
||||
- pose: 40/40 admitted;
|
||||
- camera init: 1/1 admitted;
|
||||
- source run completed in 4.147395 seconds;
|
||||
- the worker saw 23 ordered events and no session-end, as intended.
|
||||
|
||||
After disconnect, the bounded derived queues behaved as designed:
|
||||
|
||||
| Queue | Consumed | Overflow drops | Retained final depth / capacity |
|
||||
|---|---:|---:|---:|
|
||||
| camera frame | 10 | 29 | 2 / 2 |
|
||||
| LiDAR | 6 | 30 | 2 / 2 |
|
||||
| pose | 6 | 18 | 16 / 16 |
|
||||
| control | 1 | 0 | 1 / 4 |
|
||||
|
||||
Exact accounting holds for every modality: consumed + dropped + retained equals
|
||||
published. The consumer failure did not stop, delay or throw into the source
|
||||
publisher. This is the required fail-open property for raw capture and the
|
||||
required fail-closed property for derived perception availability.
|
||||
|
||||
## Transport and security boundary
|
||||
|
||||
- Mission Core remains bound to `127.0.0.1`.
|
||||
- A private 0600 bearer token is stored under
|
||||
`.runtime/live-perception/shadow-worker.token`; it is not returned in plugin
|
||||
state or passed on a remote command line.
|
||||
- The Mac opens an SSH reverse tunnel using the pinned `mission-gpu` host key.
|
||||
- The worker receives the token on stdin and connects through
|
||||
`host.docker.internal` to that tunnel.
|
||||
- The server permits one consumer lease and immediately releases it on client
|
||||
disconnect.
|
||||
- Every binary event contains an explicit schema, bounded header, exact payload
|
||||
length and SHA-256 digest.
|
||||
- The worker container was read-only, capability-dropped, `no-new-privileges`,
|
||||
PID-limited and given only read-only runner plus D-backed report mounts.
|
||||
- No GPU access was required for this transport-only probe.
|
||||
- The worker never received a K1 address and never opened MQTT or RTSP.
|
||||
|
||||
## Storage and runtime
|
||||
|
||||
All task-controlled Windows files remained under `D:\NDC_MISSIONCORE`:
|
||||
|
||||
- runner:
|
||||
`D:\NDC_MISSIONCORE\workspace\mission-core-compute\run_e12_shadow_transport_probe.py`;
|
||||
- report root:
|
||||
`D:\NDC_MISSIONCORE\runtime\derived\e12-shadow-transport`.
|
||||
|
||||
Windows drive C was not used by this task. D had approximately 375.75 GiB free
|
||||
before the nominal run and 376.06 GiB at the final read; the report is only a
|
||||
small JSON document and raw payloads were not retained on the worker.
|
||||
|
||||
## Artifact and producer identity
|
||||
|
||||
- nominal edge report:
|
||||
`.runtime/compute-experiments/e12/20260722T065957Z-e12-source.json`,
|
||||
SHA-256 `36018374bc82b720d6ee50dace9ca1621431a5f90f49bfaf824054ecf5e6dd15`;
|
||||
- nominal D-worker report:
|
||||
`D:\NDC_MISSIONCORE\runtime\derived\e12-shadow-transport\20260722T065955Z-e12-shadow-transport.json`,
|
||||
SHA-256 `4983b6bcb489892170d7dbc8014dc04e4576202a8590394658259dd8d1b42de6`;
|
||||
- negative edge report:
|
||||
`.runtime/compute-experiments/e12/20260722T070212Z-e12-source.json`,
|
||||
SHA-256 `2cc4f45959b4ce58fa2a58e004a6572a26a483b33e458f46a471012bf5abda74`;
|
||||
- negative worker report:
|
||||
`.runtime/compute-experiments/e12/local-negative/20260722T070209Z-e12-shadow-transport.json`,
|
||||
SHA-256 `311e34f3e514f77caf0a36355902b15a2fc22cd861ecbf00a91d00f08a695a63`;
|
||||
- ingress implementation SHA-256:
|
||||
`73e9359d926f7004ebe9519e0de4a05e4230922b714058ded9d27eff55ca06c0`;
|
||||
- authenticated router SHA-256:
|
||||
`4c409299d99304a0330256113f188c0bca97aadab6f742a9a626c059a91c0ce6`;
|
||||
- camera raw-first hook SHA-256:
|
||||
`086adf61d8143587025b85f46a59c9af746eee5656213cc1aa1c80974592d0a9`;
|
||||
- physical acquisition integration SHA-256:
|
||||
`a9aaf129f083cfb219767e13765b8575fc62df8e0ed05e6d50cff00c45ef8186`;
|
||||
- near-live source runner SHA-256:
|
||||
`9492804d8ecb629cb4a43b7b2d3220484c7b545b2e53dbad09eb8069f680d17d`;
|
||||
- D-worker transport probe SHA-256 on both hosts:
|
||||
`0ad67a1eb6935dd111c490128645b0d4345dc86d171dd7c4045ba990287da44d`.
|
||||
|
||||
## Accepted scope
|
||||
|
||||
- raw-first post-commit camera/MQTT fan-out;
|
||||
- separate bounded modality queues;
|
||||
- exclusive authenticated worker lease;
|
||||
- source-rate camera/LiDAR/pose transport through the real Windows/Docker path;
|
||||
- per-event integrity and ordered lifecycle;
|
||||
- worker-disconnect isolation from the source;
|
||||
- D-only worker persistence;
|
||||
- zero K1 command authority.
|
||||
|
||||
## Not accepted
|
||||
|
||||
- a physical live K1 run in this experiment;
|
||||
- live camera decode on the worker;
|
||||
- live detector, semantic model or LiDAR fusion execution over this transport;
|
||||
- cross-host latency based on unsynchronised wall clocks;
|
||||
- long-duration soak, reconnect/recovery or worker-process restart;
|
||||
- forest/off-road domain accuracy;
|
||||
- vehicle-body extrinsics, navigation or safety use.
|
||||
|
||||
## Next gate
|
||||
|
||||
1. Attach the worker's persistent camera decoder to the camera-init/frame branch.
|
||||
2. Feed decoded frames to the already accepted capacity-2 10 Hz detector queue.
|
||||
3. Decode LiDAR/pose into their verified K1 schemas and carry the latest matched
|
||||
depth epoch into the existing E10 fusion runtime.
|
||||
4. Run YOLOX/Triton at 10 Hz and EoMT semantics at 2 Hz without writing per-frame
|
||||
images; publish only transient diagnostic world state and telemetry.
|
||||
5. Repeat the disconnect negative control with inference loaded.
|
||||
6. When K1 is awake, run the same profile first for 15 seconds and then for 60
|
||||
seconds against the physical acquisition path before any longer soak.
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
# LAB E13 — class-aware amodal 3D cuboids
|
||||
|
||||
Дата прогона: 2026-07-22
|
||||
Статус: accepted for recorded near-live qualification; not accepted for navigation or safety
|
||||
Основной результат: `e10-integrated-perception-eaf7427637326e74dbc5fe66aaffcab83d0a6c18c0c45b93fdc2ecff062b94ee`
|
||||
|
||||
## 1. Цель
|
||||
|
||||
LAB E13 заменяет диагностические p05–p95 оболочки LAB E10/E11 на полные 3D cuboids, пригодные для дальнейшей разработки «глаз» беспилотника. Старый fitter описывал только видимую LiDAR-поверхность: например, у автомобиля получалась длинная, но очень тонкая пластина. E13 должен:
|
||||
|
||||
- сохранить реальное LiDAR-свидетельство отдельно от достроенной геометрии;
|
||||
- получить полный class-aware объём car/truck/bus/person/bicycle/motorcycle;
|
||||
- привязать нижнюю грань к локальной оценке земли;
|
||||
- стабилизировать центр, размеры и yaw по `track_id`;
|
||||
- не публиковать старую оболочку как полноценный 3D object box, если достройка не прошла проверки;
|
||||
- остаться в ранее принятом near-live бюджете: detector около 10 FPS, semantic около 2 FPS, p95 возраста world state не более 175 ms.
|
||||
|
||||
E13 не утверждает, что невидимая часть объекта измерена. Полный cuboid явно маркируется как `class-prior-completed-from-visible-lidar-support`, а исходная измеренная оболочка сохраняется в `observed_cuboid_*`.
|
||||
|
||||
## 2. Входные данные
|
||||
|
||||
- Сессия: `20260720T065719Z_viewer_live` (`RAVNOVES00`).
|
||||
- Камера: `sensor.camera.right`, XGRIDS calibration slot `camera_1`.
|
||||
- Калибровка SHA-256: `05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`.
|
||||
- Camera compute job: `recorded-camera-602ac89026ed12978619801d`.
|
||||
- LiDAR pack: `e10-lidar-pack-5da0396d32a27f9d1ca537cc2e8a371d386078d6f0dc71737b78620992af9625`.
|
||||
- Кадры: 1000–1600 включительно, 601 кадр.
|
||||
- Временной интервал общей шкалы: 135.365857292–195.334857292 s.
|
||||
- Source span: 59.969 s.
|
||||
- Timeline SHA-256: `06e3514228b488137b92c303cbca171154ede00368783e32107ed2c39c41baa6`.
|
||||
- Valid-FOV mask: 270,606 из 480,000 пикселей.
|
||||
- Valid-FOV mask SHA-256: `a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`.
|
||||
|
||||
## 3. Вычислительный контур
|
||||
|
||||
- Worker: `<worker-host>`, выполнение только на диске `D:`.
|
||||
- GPU: NVIDIA GeForce RTX 4090.
|
||||
- Контейнер: `nvcr.io/nvidia/tritonserver:26.06-py3`.
|
||||
- Python 3.12.3; NumPy 1.26.4; SciPy 1.16.3; PyTorch 2.13.0+cu130; Transformers 4.57.6.
|
||||
- Detector: YOLOX-S, COCO-80, Triton, 640×640.
|
||||
- Detector model SHA-256: `c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`.
|
||||
- Semantic model: `tue-mps/cityscapes_semantic_eomt_large_1024`, FP16 autocast.
|
||||
- Semantic model SHA-256: `c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`.
|
||||
- Source pace: 1.0×.
|
||||
- Detector queue: latest-wins capacity 2.
|
||||
- Semantic queue: latest-wins capacity 1; sampling every fifth camera frame.
|
||||
- Profile SHA-256: `1890d5039d93abea29bd75cf5609264d64b2c0b0fa5b032233fa5e45872776cd`.
|
||||
- Runner SHA-256: `bf04efa64c3614688742fb28d2da7c428ff70b4ca61a513318cd2440e79ff776`.
|
||||
- Fusion runtime SHA-256: `9724a4b5af719ea902f02a8a1efdb720e64b5af68b5759367bdc756bdd31b5c9`.
|
||||
- PowerShell orchestrator SHA-256: `ac0ee42495839773a2de60090ebcb49eee979dc7ee3b8587662a3c59e19a4d03`.
|
||||
|
||||
Перед финальным прогоном на `D:` было 375.612 GiB свободно при жёстком floor 360 GiB. После трёх сохранённых A/B-прогонов и публикации текущий объём свободного места составлял 375.317 GiB. Диск `C:` не использовался и не проверялся.
|
||||
|
||||
## 4. Алгоритм E13
|
||||
|
||||
### 4.1 Измеренная часть
|
||||
|
||||
Сохраняется прежний доказанный контур:
|
||||
|
||||
1. calibrated KB4 projection LiDAR → camera;
|
||||
2. nearest-depth-per-rounded-pixel occlusion gate;
|
||||
3. пересечение 2D track, semantic class mask и LiDAR projection;
|
||||
4. depth-contiguous cluster;
|
||||
5. spatial connected cluster;
|
||||
6. robust oriented p05–p95 envelope как `observed_cuboid`.
|
||||
|
||||
### 4.2 Достройка полного объёма
|
||||
|
||||
- Для каждого detector label задан nominal/min/max размер и support padding.
|
||||
- Видимая грань достраивается от сенсора, то есть невидимый объём продолжается за измеренной поверхностью, а не симметрично в обе стороны.
|
||||
- Для вытянутых классов fitter различает длинную боковую поверхность и короткую фронтальную/заднюю грань. При короткой грани yaw поворачивается на 90° по нормали поверхности.
|
||||
- При слабой анизотропии ориентация берётся из track history; при отсутствии истории используется sensor-bearing fallback.
|
||||
- Локальная земля оценивается по нижнему процентилю полного облака в радиусе 2.5 m вокруг объекта. Нереалистичная оценка заменяется ограниченным fallback относительно нижней границы support points.
|
||||
- Размеры ограничиваются физическими class-bounds. Выход за bound означает rejection, а не расширение коробки до заведомо ложного размера.
|
||||
- Центр, размер и yaw фильтруются по `track_id`.
|
||||
- Cuboid должен содержать не менее 75% текущих support points.
|
||||
- Если temporal smoothing уводит box от текущих точек, фильтр сбрасывается на текущий несглаженный candidate.
|
||||
- `failure_policy=reject`: если полный cuboid нельзя построить честно, 2D detection и observed support остаются в журнале, но 3D box не публикуется.
|
||||
|
||||
### 4.3 Классовые nominal sizes
|
||||
|
||||
| Detector label | Nominal L×W×H, m | Допустимый диапазон, m |
|
||||
|---|---:|---:|
|
||||
| person | 0.55×0.55×1.72 | 0.35–1.20 × 0.35–1.20 × 1.30–2.30 |
|
||||
| bicycle | 1.80×0.65×1.50 | 1.20–2.50 × 0.40–1.20 × 1.00–2.20 |
|
||||
| motorcycle | 2.10×0.80×1.45 | 1.40–3.00 × 0.50–1.40 × 1.00–2.20 |
|
||||
| car | 4.50×1.85×1.55 | 3.20–5.80 × 1.45–2.40 × 1.20–2.30 |
|
||||
| truck | 7.00×2.50×3.00 | 4.80–12.50 × 1.80–3.20 × 1.80–4.20 |
|
||||
| bus | 10.50×2.55×3.20 | 7.00–13.50 × 2.10–3.20 × 2.50–4.20 |
|
||||
|
||||
## 5. Итерации A/B
|
||||
|
||||
### E13.1 — completion с визуальным fallback
|
||||
|
||||
- Result: `e10-integrated-perception-54e660dc90333f5d38b9ad7054e05e32a808e268080220db20d2d2a1141cc2c3`.
|
||||
- 494 опубликованных 3D boxes: 382 class-prior-completed и 112 fallback visible envelopes.
|
||||
- World-state p95 age: 97.876 ms.
|
||||
- Решение отклонено как финальное: интерфейс смешивал полные cuboids и старые короткие оболочки.
|
||||
|
||||
### E13.2 — fail-closed и yaw axis hysteresis
|
||||
|
||||
- Result: `e10-integrated-perception-b35c3e94e8c8614ad4cf51a5b9a41ebe5a7be856aa1b3cc837b2d9bd6f05e8fe`.
|
||||
- Только 334 class-prior-completed boxes опубликованы.
|
||||
- 81 rejection по support coverage и 79 rejection по class-size bounds.
|
||||
- 90°/innovation resets снижены с 76 до 16.
|
||||
- World-state p95 age: 100.072 ms.
|
||||
- Найден дефект: при coverage reset смешивались новый центр/yaw и ранее сглаженный размер.
|
||||
|
||||
### E13.3 — полный current-candidate reset
|
||||
|
||||
- Result: `e10-integrated-perception-eaf7427637326e74dbc5fe66aaffcab83d0a6c18c0c45b93fdc2ecff062b94ee`.
|
||||
- 375 class-prior-completed boxes опубликованы.
|
||||
- Coverage rejections снижены с 81 до 39.
|
||||
- Class-bound rejections: 81; они сохранены как корректный fail-closed outcome.
|
||||
- Innovation resets: 15.
|
||||
- Финальная итерация принята.
|
||||
|
||||
## 6. Финальные результаты
|
||||
|
||||
### 6.1 Throughput и latency
|
||||
|
||||
| Метрика | LAB E10 baseline, тот же clip | LAB E13.3 | Gate |
|
||||
|---|---:|---:|---:|
|
||||
| Detector effective FPS | 9.996 | 9.986 | ≥9.5 |
|
||||
| Detector dropped frames | 0 | 0 | 0 |
|
||||
| Semantic effective FPS | 2.013 | 2.011 | ≥1.8 |
|
||||
| Fresh semantic coverage | 99.33% | 99.00% | ≥90% |
|
||||
| Fused frames | 525 | 523 | ≥450 |
|
||||
| Published 3D boxes | 760 visible envelopes | 375 completed cuboids | ≥100 |
|
||||
| Association p50 | 1.391 ms | 1.751 ms | diagnostic |
|
||||
| Association p95 | 3.858 ms | 5.184 ms | diagnostic |
|
||||
| World-state age p50 | 52.017 ms | 68.547 ms | diagnostic |
|
||||
| World-state age p95 | 76.515 ms | 101.829 ms | ≤175 ms |
|
||||
| Replay wall time | 60.123 s | 60.184 s | about 1.0× |
|
||||
|
||||
Добавленная геометрия увеличила p95 association примерно на 1.33 ms. Разница полного world-state age между отдельными запусками также содержит вариативность decode, detector и semantic runtimes; её нельзя целиком приписывать cuboid fitter.
|
||||
|
||||
### 6.2 Ресурсы
|
||||
|
||||
- Process peak RSS: 2022.719 MiB.
|
||||
- CUDA peak allocated: 2107.925 MiB.
|
||||
- CUDA peak reserved: 2840 MiB.
|
||||
- GPU telemetry mean/max utilization: 63.77% / 79%.
|
||||
- GPU telemetry mean/max memory used: 13,381 / 13,391 MiB.
|
||||
- GPU power mean/max: 215.40 / 228.72 W.
|
||||
- GPU temperature mean/max: 46 / 49 °C.
|
||||
|
||||
### 6.3 Геометрия и качество фильтра
|
||||
|
||||
- Accepted cuboids: 375.
|
||||
- По классам: car 267; truck 100; person 6; bicycle 2; motorcycle/bus 0 в accepted set.
|
||||
- Mean support coverage: 85.82%.
|
||||
- Mean completion fraction: 92.67%; p50 95.06%; p95 99.46%.
|
||||
- Средний accepted car box: 4.536×1.891×1.569 m.
|
||||
- Средний accepted truck box: 7.080×2.500×3.001 m.
|
||||
- Средний accepted person box: 0.572×0.618×1.720 m.
|
||||
- Temporal statuses: confirmed 185; tentative 115; reset-support-coverage 60; reset-innovation 15.
|
||||
- Orientation sources: support-PCA 196; support-face-normal 129; track-history axis disambiguation 48; track-history 1; sensor-bearing fallback 1.
|
||||
|
||||
На 256 соседних accepted observations одного track с разрывом не более 0.25 s:
|
||||
|
||||
| Temporal metric | Measured visible envelope | Completed E13 cuboid |
|
||||
|---|---:|---:|
|
||||
| Median size step | 0.548 m | 0.000 m |
|
||||
| p95 size step | 2.167 m | 0.152 m |
|
||||
| Median yaw step | 4.67° | 1.31° |
|
||||
| p95 yaw step | 58.61° | 22.10° |
|
||||
|
||||
Это не является сравнением с ground truth, но подтверждает, что размер и yaw перестали повторять шум каждой отдельной видимой поверхности.
|
||||
|
||||
### 6.4 Fail-closed accounting
|
||||
|
||||
- `rejected-fewer-than-8-clustered-points`: 1288 vehicle observations.
|
||||
- `rejected-fewer-than-4-clustered-points`: 66 non-vehicle observations.
|
||||
- `rejected-no-semantic-lidar-support`: 590.
|
||||
- `rejected-distance-innovation`: 98.
|
||||
- `rejected-amodal-completion-required-size-exceeds-class-bound`: 81.
|
||||
- `rejected-amodal-completion-support-coverage-below-threshold`: 39.
|
||||
- `rejected-implausible-cuboid`: 12.
|
||||
|
||||
Высокое число rejected candidates ожидаемо: E13 сознательно уменьшает визуальный мусор и публикует только полный cuboid, прошедший semantic, clustering, distance, class-size и support-coverage gates.
|
||||
|
||||
## 7. Визуальная проверка и публикация
|
||||
|
||||
- Результат прошёл `validate_integrated_perception_result` локально.
|
||||
- Rerun perception overlay: 58,326,068 bytes; API build time 9.778 s; HTTP 200.
|
||||
- E13 опубликован как newest accepted overlay для `RAVNOVES00`.
|
||||
- Проверено в Mission Core на общей шкале около 02:27: cloud, trajectory, recorded camera, semantic overlay и полные полупрозрачные 3D cuboids отображаются совместно.
|
||||
- Принятый вид оставлен открытым в локальном интерфейсе.
|
||||
|
||||
## 8. Артефакты
|
||||
|
||||
Worker root:
|
||||
|
||||
`D:\NDC_MISSIONCORE\runtime\derived\e10-integrated-perception-eaf7427637326e74dbc5fe66aaffcab83d0a6c18c0c45b93fdc2ecff062b94ee`
|
||||
|
||||
Local laboratory copy:
|
||||
|
||||
`.runtime/compute-experiments/e13/worker-results/e10-integrated-perception-eaf7427637326e74dbc5fe66aaffcab83d0a6c18c0c45b93fdc2ecff062b94ee`
|
||||
|
||||
Published overlay source:
|
||||
|
||||
`.runtime/compute-experiments/e10/worker-results/e10-integrated-perception-eaf7427637326e74dbc5fe66aaffcab83d0a6c18c0c45b93fdc2ecff062b94ee`
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `result.json` | — | `512f5fcb72d3c10f4d3d6beb081b28dbb65addcd166e7d4a4bdc3f55a63be4c6` |
|
||||
| `semantic-frames.jsonl` | 55,023 | `43e72dc9f3ec9fc74f4e1c69dc1ba2fd900ac445aa1744f8d5ea2cc2165cd52b` |
|
||||
| `fusion-frames.jsonl` | 2,096,208 | `ed8256b41c8a56b8d1dd561e6c47479932a913c80e64cf73c4dc761722637acf` |
|
||||
| `world-state.jsonl` | 1,336,724 | `a418d1b90fddd810b3ec3fa20c6e5bfa14ecaf2f348500f4a9b56a761a2063d0` |
|
||||
| `transient-perception.npz` | 1,065,071 | `a68e5bd06eee4f2372eacaf0d94326515b94848189e6527d063002398d0c0f56` |
|
||||
| `gpu-telemetry.jsonl` | 13,704 | `a80cced041a87446cfb9e074a0867ab5f980108f826b347ca5175664d2b9c907` |
|
||||
| `run-report.json` | 17,870 | `b6e67b710e6a2f9d3132a0293ac1fae54ca688b6a1ebbc42174893388c0ab88d` |
|
||||
|
||||
## 9. Ограничения
|
||||
|
||||
1. Это recorded source-paced qualification, а не физический live K1 → worker прогон.
|
||||
2. Host-arrival camera/LiDAR binding не доказывает hardware-clock synchronization.
|
||||
3. COCO YOLOX и Cityscapes EoMT не являются forest-domain или safety-validated моделями.
|
||||
4. Средняя доля достроенного объёма 92.67% показывает, что текущие «нормальные» cuboids в основном опираются на class priors. Это инженерный amodal estimate, не измеренная форма объекта.
|
||||
5. 3D ground truth отсутствует. Нельзя утверждать 3D IoU, heading accuracy, center error или recall относительно истины.
|
||||
6. Truck detections наследуют ограниченность COCO label и могут включать крупные автомобили, которые в конкретном кадре классифицированы неточно.
|
||||
7. Current local-ground percentile не заменяет полноценную road/terrain plane model для уклонов, бордюров и лесной поверхности.
|
||||
8. E13 не имеет navigation/control authority и не принят для safety.
|
||||
|
||||
## 10. Решение и следующий gate
|
||||
|
||||
E13.3 принят как новый визуальный и world-state baseline вместо visible-surface boxes. Он доказывает, что class-aware full cuboids и temporal stabilization укладываются в near-live бюджет на существующем worker.
|
||||
|
||||
Следующий обязательный этап — LAB E14:
|
||||
|
||||
1. добавить 3D ground-truth/prelabel contour хотя бы для репрезентативного набора кадров;
|
||||
2. провести внешний benchmark learned LiDAR/BEV 3D detectors (CenterPoint/PointPillars и camera-LiDAR fusion вариант) на том же K1 point pattern;
|
||||
3. сравнить learned box с E13 prior-completed baseline по latency, 3D IoU/center/yaw и стабильности;
|
||||
4. подключить принятый fitter к bounded E12 shadow ingress и проверить disconnect/overflow вместе с inference;
|
||||
5. после этого выполнить физический live K1 gate без control/navigation authority.
|
||||
|
||||
До появления 3D labels E13 следует использовать как честно маркированный near-live amodal baseline, а не как финальное решение 3D detection.
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
# LAB E14 — full-session recorded near-live qualification
|
||||
|
||||
Дата прогона: 2026-07-22
|
||||
Статус вычислительного прогона: accepted for recorded near-live qualification
|
||||
Статус physical live K1: not tested
|
||||
Navigation/safety acceptance: false
|
||||
Основной результат: `e10-integrated-perception-9c10dc36640b409393c161f6cbd59b000581b39851ef7a8931898cf8f46d7e8b`
|
||||
|
||||
## 1. Цель
|
||||
|
||||
LAB E14 переносит принятый в E13 class-aware amodal 3D cuboid contour с минутной выборки на всю доступную запись `RAVNOVES00`. Эксперимент отвечает на практический вопрос: может ли текущий RTX 4090 worker непрерывно обрабатывать полный recorded stream в темпе источника, одновременно выполняя:
|
||||
|
||||
- декодирование fisheye-видео;
|
||||
- YOLOX-S 2D detection на каждом camera frame;
|
||||
- EoMT semantic segmentation на каждом пятом frame;
|
||||
- calibrated KB4 camera–LiDAR projection;
|
||||
- semantic/LiDAR association;
|
||||
- class-prior amodal 3D cuboid completion и temporal stabilization;
|
||||
- clearance и world-state publication;
|
||||
- GPU/runtime telemetry;
|
||||
- fail-closed accounting без скрытых потерь очередей.
|
||||
|
||||
E14 является capacity qualification на записанном источнике. Он не заменяет физический live K1 gate, не подтверждает hardware-clock synchronization и не выдаёт контуру право управлять беспилотником.
|
||||
|
||||
## 2. Входные данные
|
||||
|
||||
- Сессия: `20260720T065719Z_viewer_live`, операторское имя `RAVNOVES00`.
|
||||
- Камера: `sensor.camera.right`.
|
||||
- XGRIDS calibration slot: `camera_1`.
|
||||
- Калибровка SHA-256: `05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`.
|
||||
- Camera compute job: `recorded-camera-602ac89026ed12978619801d`.
|
||||
- Camera input SHA-256: `602ac89026ed12978619801d4edea0cae24b5cc3afabd9f7af2858de6505a20e`.
|
||||
- LiDAR pack: `e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b`.
|
||||
- Кадры: 0–4488 включительно, 4489 camera frames.
|
||||
- Timeline: 35.421857292–484.044857292 s.
|
||||
- Source span: 448.623 s.
|
||||
- Timeline SHA-256: `f91dad0a3cc998f4250be794b48f437943b3c60edccbe7e5ceed59030854a807`.
|
||||
- Разрешение: 800×600.
|
||||
- Valid-FOV mask: 270,606 из 480,000 пикселей, 56.38% кадра.
|
||||
- Valid-FOV mask SHA-256: `a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`.
|
||||
|
||||
Valid-FOV mask вычислена заранее из стабильной геометрии объектива и переиспользуется. За пределами полезного круга detector/semantic contour не получает содержательного изображения; маска не пересчитывается для каждого кадра.
|
||||
|
||||
## 3. Воспроизводимая конфигурация
|
||||
|
||||
### 3.1 Worker и модели
|
||||
|
||||
- Worker host: `<worker-host>`.
|
||||
- GPU: NVIDIA GeForce RTX 4090.
|
||||
- Worker storage: только `D:\NDC_MISSIONCORE`.
|
||||
- Диск `C:` не использовался и не проверялся.
|
||||
- Container image: `nvcr.io/nvidia/tritonserver:26.06-py3`.
|
||||
- Python 3.12.3; NumPy 1.26.4; SciPy 1.16.3; PyTorch 2.13.0+cu130; Transformers 4.57.6.
|
||||
- Detector: YOLOX-S, COCO-80, Triton, 640×640.
|
||||
- Detector model SHA-256: `c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`.
|
||||
- Semantic: `tue-mps/cityscapes_semantic_eomt_large_1024`, FP16 autocast.
|
||||
- Semantic model revision: `8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f`.
|
||||
- Semantic model SHA-256: `c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`.
|
||||
|
||||
### 3.2 Scheduler и профили
|
||||
|
||||
- Source pace: 1.0×.
|
||||
- Detector: каждый frame, latest-wins queue capacity 2.
|
||||
- Semantic: каждый пятый frame, latest-wins queue capacity 1.
|
||||
- Semantic TTL: 750 ms.
|
||||
- Profile: `experiments/perception/worker/e14_full_session_amodal_profile.json`.
|
||||
- Profile SHA-256: `a010e32070459b123423f3d4f57aa1d839f73ed05f3df8580d12693406a1b218`.
|
||||
- Detector profile SHA-256: `819bdfb0da521d187ec9ce3d3039a0ff75daa0ab473fb3d8e5fa92db9c1ddca1`.
|
||||
- Semantic profile SHA-256: `ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`.
|
||||
- Runner SHA-256: `bf04efa64c3614688742fb28d2da7c428ff70b4ca61a513318cd2440e79ff776`.
|
||||
- Fusion runtime SHA-256: `9724a4b5af719ea902f02a8a1efdb720e64b5af68b5759367bdc756bdd31b5c9`.
|
||||
- PowerShell orchestrator SHA-256: `ac0ee42495839773a2de60090ebcb49eee979dc7ee3b8587662a3c59e19a4d03`.
|
||||
- Runner generation: `D:\NDC_MISSIONCORE\runtime\derived\e14-runner-bf04efa64c36-ac0ee4249583-a010e3207045`.
|
||||
|
||||
### 3.3 Acceptance gates
|
||||
|
||||
| Gate | Порог |
|
||||
|---|---:|
|
||||
| Detector effective FPS | ≥9.5 |
|
||||
| Detector drop fraction | ≤0.5% |
|
||||
| Semantic effective FPS | ≥1.8 |
|
||||
| Semantic drop fraction | ≤5% |
|
||||
| Semantic completion age p95 | ≤400 ms |
|
||||
| Fresh semantic coverage | ≥90% |
|
||||
| World-state age p95 | ≤175 ms |
|
||||
| Fused frames | ≥3500 |
|
||||
| Accepted completed cuboids | ≥1500 |
|
||||
| Detector/semantic failures | 0 |
|
||||
|
||||
## 4. Выполнение и время
|
||||
|
||||
- Подготовка worker input/runtime: 31.249 s.
|
||||
- Source-paced replay внутри runner: 448.699 s.
|
||||
- Source duration: 448.623 s.
|
||||
- Replay/source ratio: 1.00017×, то есть вычислительный цикл удержал темп записи.
|
||||
- Полный PowerShell orchestration wall time: 597.752 s.
|
||||
|
||||
Разница между replay и orchestration включает подготовку, запуск/проверку контейнерного контура, сбор и валидацию артефактов, восстановление model state и финальную уборку. Она не является inference latency кадра.
|
||||
|
||||
Контур выполнялся параллельно: detector и semantic workers имели отдельные bounded queues; fusion/world-state потреблял detector result на camera cadence и последнюю допустимую semantic result. Медленная semantic модель не блокировала detector loop и не накапливала безразмерную очередь.
|
||||
|
||||
## 5. Итоговые near-live метрики
|
||||
|
||||
| Метрика | Результат | Gate | Статус |
|
||||
|---|---:|---:|---|
|
||||
| Detector frames | 4489/4489 | полное accounting | pass |
|
||||
| Detector effective FPS | 10.0045 | ≥9.5 | pass |
|
||||
| Detector drops | 0 | ≤0.5% | pass |
|
||||
| Detector queue max depth | 1 из 2 | bounded | pass |
|
||||
| Semantic frames | 898 | every fifth frame | pass |
|
||||
| Semantic effective FPS | 2.0013 | ≥1.8 | pass |
|
||||
| Semantic drops | 0 | ≤5% | pass |
|
||||
| Fresh semantic coverage | 99.7104% | ≥90% | pass |
|
||||
| Semantic completion age p95 | 261.259 ms | ≤400 ms | pass |
|
||||
| Fused frames | 3915 | ≥3500 | pass |
|
||||
| Accepted 3D cuboids | 3656 | ≥1500 | pass |
|
||||
| World-state age p50 / p95 | 67.439 / 102.883 ms | p95 ≤175 ms | pass |
|
||||
| Failures | 0 | 0 | pass |
|
||||
|
||||
Fusion state accounting:
|
||||
|
||||
- `fused`: 3915;
|
||||
- `depth-unavailable-sync-gate`: 561;
|
||||
- `semantic-stale`: 8;
|
||||
- `semantic-unavailable`: 5;
|
||||
- semantic freshness violations: 0.
|
||||
|
||||
## 6. Latency decomposition
|
||||
|
||||
| Stage | Mean, ms | p50, ms | p95, ms | Max, ms |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Decode | 33.438 | 31.333 | 48.551 | 77.638 |
|
||||
| Detector | 31.502 | 28.524 | 47.158 | 65.501 |
|
||||
| Detector queue wait | 0.202 | 0.074 | 0.200 | 23.505 |
|
||||
| Projection | 0.476 | 0.397 | 0.884 | 12.028 |
|
||||
| Association + cuboid | 2.575 | 1.964 | 6.834 | 26.413 |
|
||||
| Clearance | 0.222 | 0.187 | 0.517 | 5.949 |
|
||||
| World-state serialization | 0.075 | 0.046 | 0.233 | 0.964 |
|
||||
| End-to-end world-state age | 70.425 | 67.439 | 102.883 | 182.733 |
|
||||
|
||||
Semantic processing:
|
||||
|
||||
| Stage | Mean, ms | p95, ms | Max, ms |
|
||||
|---|---:|---:|---:|
|
||||
| Host → device | 6.869 | 10.309 | 18.386 |
|
||||
| Forward | 154.711 | 167.953 | 407.725 |
|
||||
| Model postprocess | 9.825 | 14.110 | 20.840 |
|
||||
| Full semantic processing | 188.479 | 206.391 | 434.399 |
|
||||
| Completion age | 224.099 | 261.259 | 467.008 |
|
||||
|
||||
## 7. 3D geometry
|
||||
|
||||
Accepted class-prior-completed cuboids: 3656.
|
||||
|
||||
| Class | Count | Mean L×W×H, m |
|
||||
|---|---:|---:|
|
||||
| car | 3118 | 4.514×1.881×1.575 |
|
||||
| truck | 493 | 7.016×2.514×3.125 |
|
||||
| person | 31 | 0.622×0.588×1.720 |
|
||||
| bus | 10 | 10.500×2.631×3.200 |
|
||||
| bicycle | 2 | 1.800×1.016×1.500 |
|
||||
| motorcycle | 2 | 2.100×0.874×1.450 |
|
||||
|
||||
- Mean support coverage: 86.19%.
|
||||
- Mean inferred completion fraction: 91.56%.
|
||||
- Temporal statuses: confirmed 2070; tentative 920; support-coverage reset 574; innovation reset 92.
|
||||
- Orientation: support-PCA 1890; support-face-normal 1034; track-history axis disambiguation 702; track-history 23; sensor-bearing fallback 7.
|
||||
|
||||
Высокая completion fraction означает, что значительная часть полного объёма достроена по class priors из видимой LiDAR-поверхности. Это диагностическая геометрия с измеренным support audit, а не 3D ground truth.
|
||||
|
||||
Fail-closed rejections:
|
||||
|
||||
- fewer than 8 clustered vehicle points: 10,563;
|
||||
- no semantic/LiDAR support: 4,884;
|
||||
- distance innovation: 505;
|
||||
- required size exceeds class bound: 395;
|
||||
- support coverage below threshold: 306;
|
||||
- fewer than 4 clustered non-vehicle points: 184;
|
||||
- implausible cuboid: 20.
|
||||
|
||||
Rejection сохраняет 2D/audit evidence, но не публикует ложный полноценный 3D box.
|
||||
|
||||
## 8. Ресурсы и диски
|
||||
|
||||
- Process peak RSS: 2964.414 MiB.
|
||||
- CUDA peak allocated: 2107.925 MiB.
|
||||
- CUDA peak reserved: 2840 MiB.
|
||||
- GPU utilization mean/p50/p95/max: 74.59% / 75% / 91% / 98%.
|
||||
- GPU memory used mean/p50/p95/max: 13,274.69 / 13,273 / 13,282 / 13,284 MiB.
|
||||
- GPU memory utilization mean/p95/max: 22.67% / 32% / 36%.
|
||||
- GPU power mean/p95/max: 200.44 / 208.43 / 225.21 W.
|
||||
- GPU temperature mean/p95/max: 49.09 / 54 / 55 °C.
|
||||
- Telemetry samples: 449 at 1 s interval.
|
||||
|
||||
На `D:` до staging/preparation было приблизительно 389.65 GiB свободно. Runner стартовал с 415,364,739,072 bytes и закончил с 415,598,956,544 bytes при hard floor 386,547,056,640 bytes (360 GiB). После orchestration и уборки оставалось около 387.05 GiB. Временные camera frames удалены оркестратором; model state восстановлен. Диск `C:` не затрагивался.
|
||||
|
||||
На локальном Mac опубликованный Rerun overlay занимает 443,421,386 bytes. На момент отчёта локально оставалось около 32 GiB; лишние копии видео не создавались.
|
||||
|
||||
## 9. Публикация и интерфейсная проверка
|
||||
|
||||
- Результат дважды прошёл строгую локальную `validate_integrated_perception_result`: до и после immutable publication в canonical provider root.
|
||||
- Full-session perception RRD: 443,421,386 bytes.
|
||||
- RRD SHA-256: `1dc29900ddeb09897af70c7334755823b02aeaa95f203d979be4bd75fae5080b`.
|
||||
- Mission Core API вернул HTTP 200 и сохранил immutable result-scoped cache.
|
||||
- Сервис не перезапускался и оставался healthy во время построения и браузерной проверки.
|
||||
- В Mission Core подтверждена полная шкала `00:00 / 08:55.718`.
|
||||
- В режиме `Распознавание` исходная камера и `Сегментация · камера right` синхронно менялись при воспроизведении; это не статичный кадр.
|
||||
|
||||
Во время проверки обнаружен presentation defect режима `Кубы 3D`: blueprint имел origin `/world/perception`, поэтому исключал native cloud `/world/points` и показывал cuboids в отдельной пустой сцене. Исправление выполнено в source:
|
||||
|
||||
- cuboid view перенесён на origin `/world`;
|
||||
- native cloud и `/world/perception` объединены в одном latest-at view;
|
||||
- 12-second accumulation к cuboid view не применяется, поэтому коробки разных кадров не наслаиваются;
|
||||
- дублирующий серый `/world/perception/lidar` скрыт;
|
||||
- добавлены regression assertions для initial и dynamic blueprints.
|
||||
|
||||
Backend не перезапускался из-за операторского требования никогда не оставлять Mission Core недоступным. Поэтому вычислительный 3D result принят, source fix протестирован, но визуальная runtime-проверка исправленного blueprint остаётся отдельным коротким gate после следующего штатного перезапуска сервиса.
|
||||
|
||||
## 10. Артефакты
|
||||
|
||||
Worker result root:
|
||||
|
||||
`D:\NDC_MISSIONCORE\runtime\derived\e10-integrated-perception-9c10dc36640b409393c161f6cbd59b000581b39851ef7a8931898cf8f46d7e8b`
|
||||
|
||||
Local laboratory copy:
|
||||
|
||||
`.runtime/compute-experiments/e14/worker-results/e10-integrated-perception-9c10dc36640b409393c161f6cbd59b000581b39851ef7a8931898cf8f46d7e8b`
|
||||
|
||||
Published provider root:
|
||||
|
||||
`.runtime/compute-experiments/e10/worker-results/e10-integrated-perception-9c10dc36640b409393c161f6cbd59b000581b39851ef7a8931898cf8f46d7e8b`
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `result.json` | 13,799 | `ff129ee1e51e19b514ffb790a145cf17292bd49e9256de2a77a2ca2557e50e68` |
|
||||
| `run-report.json` | 18,144 | `a85a714215358715120efb057991087f8b214fe6ef86ce2f68124f19287f2a4d` |
|
||||
| `semantic-frames.jsonl` | 404,398 | `0156bd7450cf6e89fcc3e66831377e87089c6f322a0db382d7135246241cf6b3` |
|
||||
| `fusion-frames.jsonl` | 17,008,643 | `04c6589c0d5258b47937114c80a1562b6a427456b22e8aedb8cfa86741fe183b` |
|
||||
| `world-state.jsonl` | 10,699,803 | `5b030270c4ae9029690666cbde7e6191df4129f393a8530dc561b76a68b61f89` |
|
||||
| `transient-perception.npz` | 8,291,832 | `36677d1b2f4260f821dcf29929dc0dea90b28a4e74ff5c777d320dda7213f659` |
|
||||
| `gpu-telemetry.jsonl` | 100,917 | `d4fb026b46d25b97b7d35397d598fec23647276af6339c98898cbfea97345601` |
|
||||
| full-session `perception.rrd` | 443,421,386 | `1dc29900ddeb09897af70c7334755823b02aeaa95f203d979be4bd75fae5080b` |
|
||||
|
||||
## 11. Что доказано и что не доказано
|
||||
|
||||
Доказано:
|
||||
|
||||
1. На RTX 4090 текущий YOLOX + EoMT + calibrated LiDAR fusion + E13 cuboid/world-state contour удерживает полный 448.623-second recorded stream в 1.0×.
|
||||
2. Detector и semantic queues bounded; за 4489 frames нет ни overflow drops, ни model failures.
|
||||
3. p95 world-state age 102.883 ms оставляет запас до лабораторного 175 ms gate.
|
||||
4. Full-session 2D/semantic playback опубликован и работает на общей временной шкале Mission Core.
|
||||
5. Все 3656 опубликованных 3D boxes прошли class bounds и support gates; rejected candidates не маскируются.
|
||||
|
||||
Не доказано:
|
||||
|
||||
1. Физический K1 live ingress → decode → inference → fusion на реальном сетевом потоке.
|
||||
2. Hardware-clock camera/LiDAR synchronization.
|
||||
3. Устойчивость к реальному disconnect/reconnect при работающем inference.
|
||||
4. Forest-domain качество COCO/Cityscapes моделей.
|
||||
5. 3D IoU, center/yaw error и recall относительно ground truth.
|
||||
6. Navigation/safety authority.
|
||||
|
||||
## 12. Следующий gate
|
||||
|
||||
Следующий этап не должен улучшать recorded-видео в вакууме. Он переносит доказанный E14 compute contour на уже принятый E12 ingress:
|
||||
|
||||
1. подключить persistent fMP4 camera decoder к bounded shadow transport;
|
||||
2. выполнять detector на 10 Hz и semantic на 2 Hz через latest-wins queues;
|
||||
3. декодировать LiDAR/pose и публиковать тот же calibrated fusion/world-state contract;
|
||||
4. вывести live telemetry: source age, decode/inference/fusion age, queue depth, drops, semantic freshness, reconnect generation;
|
||||
5. выполнить физический K1 shadow gate 15 s;
|
||||
6. при нулевых gaps/failures выполнить 60 s gate;
|
||||
7. только после этого увеличивать длительность и проверять disconnect/reconnect;
|
||||
8. control/navigation output оставить физически отключённым.
|
||||
|
||||
Отдельная короткая presentation-проверка после следующего штатного перезапуска Mission Core должна подтвердить, что исправленный `Кубы 3D` показывает latest-at cuboids непосредственно поверх native point cloud.
|
||||
|
|
@ -0,0 +1,351 @@
|
|||
# LAB E15 — source-paced live shadow inference qualification
|
||||
|
||||
Дата: 22 июля 2026 года
|
||||
Финальный accepted run: 20:23–20:26 MSK / `2026-07-22T17:26:37Z` source completion
|
||||
Статус: **accepted для recorded-source-paced shadow diagnostic**
|
||||
Safety/navigation authority: **не выдана**
|
||||
|
||||
## 1. Что проверялось
|
||||
|
||||
LAB E15 впервые соединяет принятые ранее части Mission Core в один потоковый контур, близкий к будущему onboard perception loop:
|
||||
|
||||
1. bounded Mission Core shadow ingress;
|
||||
2. инкрементальный decode сохранённого fMP4 без сборки видео целиком и без PNG на диске;
|
||||
3. YOLOX-S object detection на каждом camera frame;
|
||||
4. EoMT semantic segmentation на каждом пятом camera frame;
|
||||
5. nearest-time camera → LiDAR → pose binding;
|
||||
6. KB4 calibrated camera–LiDAR projection;
|
||||
7. semantic/LiDAR association;
|
||||
8. class-prior amodal 3D cuboids;
|
||||
9. clearance и timestamped world-state;
|
||||
10. bounded queues, telemetry и fail-closed acceptance.
|
||||
|
||||
Это не offline-просчёт «как можно качественнее». Источник отдавал 15 секунд реальной записи в темпе 1.0×, а worker обязан был удерживать camera cadence, bounded memory и ограничение на возраст результата.
|
||||
|
||||
## 2. Граница доказательства
|
||||
|
||||
Доказано:
|
||||
|
||||
- один RTX 4090 worker принимает camera/LiDAR/pose через live shadow wire contract;
|
||||
- 151 fMP4 fragment декодируется инкрементально без потерь и без временных frame-файлов;
|
||||
- detector работает практически на camera cadence 10 Hz;
|
||||
- semantic contour работает примерно на 2 Hz и не блокирует detector;
|
||||
- LiDAR/pose fusion и 3D cuboid/world-state укладываются в заданный p95 age budget;
|
||||
- transport, model queues и media buffer остаются bounded;
|
||||
- disconnect/replay transport не вмешивается в raw recorder;
|
||||
- весь persistent worker contour и результаты размещены только на `D:`.
|
||||
|
||||
Не доказано:
|
||||
|
||||
- работа от физически подключённого K1 в этом конкретном E15 run;
|
||||
- hardware-clock synchronization камеры и LiDAR;
|
||||
- forest-domain accuracy, recall или safety quality;
|
||||
- пригодность COCO/Cityscapes labels для автономной навигации;
|
||||
- vehicle-body transform;
|
||||
- управление беспилотником или safety authority;
|
||||
- публикация текущего live world-state в операторский Rerun UI.
|
||||
|
||||
Итоговый scope результата: `live-shadow-diagnostic-only`.
|
||||
|
||||
## 3. Входные данные и provenance
|
||||
|
||||
### 3.1 Источник
|
||||
|
||||
- Session: `20260720T065719Z_viewer_live` (`RAVNOVES00`).
|
||||
- Camera source: `sensor.camera.right`.
|
||||
- Camera calibration slot: `camera_1`.
|
||||
- Camera resolution: 800×600.
|
||||
- Source interval: 14.989973 s.
|
||||
- Replay pace: 1.0× recorded host arrival time.
|
||||
- Camera frames: 151.
|
||||
- LiDAR messages: 142.
|
||||
- Pose messages: 150.
|
||||
- Control messages: 2.
|
||||
- Total wire events: 446.
|
||||
- Camera index SHA-256: `e029815a60ad9fbfedb6169142c7449df2b119a51d1ce001f08806e04eb0be14`.
|
||||
- MQTT raw SHA-256: `70da0edad5cbf0e89b6e2355c0aac294f8c36a67249cd33b8918353fd88c83af`.
|
||||
- Accepted source report: `.runtime/compute-experiments/e12/20260722T172637Z-e12-source.json`.
|
||||
- Accepted source report SHA-256: `8d36a9715b19fcd820e585629e0774d97ea21dfd5b763fd48e9672e098814e48`.
|
||||
|
||||
### 3.2 Калибровка
|
||||
|
||||
- Calibration SHA-256: `05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`.
|
||||
- Projection model: KB4.
|
||||
- Projection pack: `e15-live-projection-713fa7344805b7a592d12e949a6db38116e5370f768bde1cf9144fab5f93f0ae`.
|
||||
- Projection arrays SHA-256: `d37bff4299b3fd6232926ba6e04d3cbd2a5aba0e66bf5ed02e0873340597566c`.
|
||||
- Pack classification: `calibration-only-no-recorded-sensor-frames`.
|
||||
- В projection pack нет записанных camera/LiDAR frames: только intrinsics, KB4 distortion и `T_camera_from_lidar`.
|
||||
|
||||
### 3.3 Valid-FOV
|
||||
|
||||
- Generation: `valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`.
|
||||
- Mask SHA-256: `a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`.
|
||||
- Valid pixels: 270,606 из 480,000.
|
||||
- Маска вычислена заранее и переиспользуется; область за пределами полезного круга не подаётся моделям как содержательное изображение.
|
||||
|
||||
## 4. Software и model configuration
|
||||
|
||||
### 4.1 Runtime
|
||||
|
||||
- Host: `<worker-host>`.
|
||||
- GPU: NVIDIA GeForce RTX 4090.
|
||||
- Container image: `nvcr.io/nvidia/tritonserver:26.06-py3@sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794`.
|
||||
- Python: 3.12.3.
|
||||
- NumPy: 1.26.4.
|
||||
- SciPy: 1.16.3.
|
||||
- PyTorch: 2.13.0+cu130.
|
||||
- Transformers: 4.57.6.
|
||||
- PyAV: 18.0.0.
|
||||
- lz4: 4.4.5.
|
||||
- Media runtime payload SHA-256: `67487da59bba27676e5a5b4e43566e956fb350f1730589d93d55f22bcddc4c6b`.
|
||||
|
||||
### 4.2 Models
|
||||
|
||||
Detector:
|
||||
|
||||
- YOLOX-S, COCO-80, Triton inference;
|
||||
- input 1×3×640×640;
|
||||
- model SHA-256: `c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
|
||||
- config SHA-256: `5795c737a7935a655961b069e8404d336d891f9762fb6dffb93956a076479604`.
|
||||
|
||||
Semantic:
|
||||
|
||||
- `tue-mps/cityscapes_semantic_eomt_large_1024`;
|
||||
- revision `8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f`;
|
||||
- FP16 autocast;
|
||||
- model SHA-256: `c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`.
|
||||
|
||||
### 4.3 Immutable code/config identities
|
||||
|
||||
- Accepted live profile SHA-256: `8b7f267ee947932bdd52d1ef4b241adc8c9b4c4d5a8a3cd6c1e7f6e066983699`.
|
||||
- E14 profile SHA-256: `a010e32070459b123423f3d4f57aa1d839f73ed05f3df8580d12693406a1b218`.
|
||||
- Detector profile SHA-256: `819bdfb0da521d187ec9ce3d3039a0ff75daa0ab473fb3d8e5fa92db9c1ddca1`.
|
||||
- Semantic profile SHA-256: `ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`.
|
||||
- Runner SHA-256: `8e487108404d912739b99d0043b8cbdd605ae148d8ad1d32ab30819ecfbb7dbc`.
|
||||
- fMP4 runtime SHA-256: `6785763350f6e2c00796f3580ea6707e0c44339f18fa8eb56687178c6f0403fc`.
|
||||
- PowerShell orchestrator SHA-256: `129bb6874730191cece10667b580fe5b8aa4e0878d442779679977079aa6069e`.
|
||||
- Minimal worker package: `e15-worker-package-dd67306b6116dd6fa091557371eb80b27afb48dae4568fb0173f253a1fb4613a`.
|
||||
- Minimal worker package identity SHA-256: `dd67306b6116dd6fa091557371eb80b27afb48dae4568fb0173f253a1fb4613a`.
|
||||
- Package содержит 11 runtime modules и не тянет MQTT/BLE/web/device lifecycle в GPU-контейнер.
|
||||
|
||||
## 5. Scheduler и bounded-state contract
|
||||
|
||||
- Detector queue: latest-wins, capacity 2.
|
||||
- Semantic queue: latest-wins, capacity 1.
|
||||
- Semantic sample: каждый пятый camera frame.
|
||||
- Semantic TTL: 750 ms.
|
||||
- Sensor buffers: 32 LiDAR + 32 pose records максимум.
|
||||
- Sensor retention: 3 s.
|
||||
- Camera metadata queue: 16 максимум.
|
||||
- Incremental media buffer: 8 MiB максимум.
|
||||
- Camera → LiDAR maximum absolute delta: 100 ms.
|
||||
- LiDAR → pose maximum absolute delta: 100 ms.
|
||||
- Accepted sensor wait budget: 90 ms.
|
||||
- Команды: disabled.
|
||||
- Navigation/safety acceptance: false.
|
||||
|
||||
Камера не блокируется semantic model. Decoder публикует frame в две bounded очереди. Detector идёт на camera cadence; semantic worker независимо обновляет latest semantic mask. Detector result связывается с latest fresh semantic result и ближайшей допустимой LiDAR/pose парой.
|
||||
|
||||
## 6. Экспериментальные попытки
|
||||
|
||||
### 6.1 Attempt A — 20 ms sensor wait, rejected
|
||||
|
||||
Result ID: `e15-shadow-inference-396152240d878551ffe4913e03aee95be0b0ec572b69d76313edd73a7ee0cf7d`.
|
||||
|
||||
Все transport, decoder, FPS, semantic, latency, queue и authority checks прошли. Единственный failed check:
|
||||
|
||||
- fusion coverage 127/151 = 84.106%; требование ≥85%.
|
||||
|
||||
Причина:
|
||||
|
||||
- 19 camera frames получили `lidar-camera-delta-exceeded`;
|
||||
- для девяти из этих frames следующий LiDAR находился всего в 49–88 ms после camera timestamp;
|
||||
- wait budget 20 ms завершал binding раньше, чем этот LiDAR был декодирован и опубликован;
|
||||
- расширять temporal gate более 100 ms не потребовалось.
|
||||
|
||||
### 6.2 Infrastructure interruption — не считается lab attempt
|
||||
|
||||
Один повтор был остановлен idle SSH timeout во время model preflight. Consumer к source не подключился, события не публиковались, immutable result не создавался. После проверки оставались только штатные `mission-core-triton`, `sentinel-frigate`, `sentinel-ollama`. Повтор выполнен с временными SSH `ServerAlive` options без изменения сетевой/VPN конфигурации.
|
||||
|
||||
### 6.3 Attempt B — 90 ms sensor wait, accepted
|
||||
|
||||
Result ID: `e15-shadow-inference-f3f06e7c6b338add445f7ee4e4a81f831cd763a899ac2fbe29ba3476c4bdf0c8`.
|
||||
|
||||
Изменён только `sensor_wait_ms`: 20 → 90. Temporal gate остался 100 ms, models и все acceptance thresholds не менялись.
|
||||
|
||||
Результат:
|
||||
|
||||
- fused frames: 136/151 = 90.066%;
|
||||
- `lidar-camera-delta-exceeded`: 19 → 10;
|
||||
- accepted cuboid states: 329 → 355;
|
||||
- p95 world-state age: 79.28 → 149.43 ms;
|
||||
- лимит p95 world-state age: 200 ms;
|
||||
- итог: accepted.
|
||||
|
||||
Это осознанный обмен дополнительного bounded ожидания на более полное depth coverage без допуска LiDAR старше 100 ms.
|
||||
|
||||
## 7. Acceptance matrix финального run
|
||||
|
||||
| Gate | Требование | Результат | State |
|
||||
|---|---:|---:|---|
|
||||
| Camera frames | ≥140 | 151 | pass |
|
||||
| Camera decode accounting | 1:1 | 151/151 | pass |
|
||||
| Detector effective FPS | ≥9.5 | 9.881 | pass |
|
||||
| Detector drops | ≤1% | 0/151 | pass |
|
||||
| Semantic effective FPS | ≥1.8 | 2.029 | pass |
|
||||
| Semantic drops | ≤5% | 0/31 | pass |
|
||||
| Semantic completion age p95 | ≤400 ms | 162.162 ms | pass |
|
||||
| Fresh semantic coverage | ≥90% | 96.689% | pass |
|
||||
| Fused fraction | ≥85% | 90.066% | pass |
|
||||
| Decode age p95 | ≤80 ms | 15.150 ms | pass |
|
||||
| World-state age p95 | ≤200 ms | 149.427 ms | pass |
|
||||
| Ingress sequence gaps | 0 | 0 | pass |
|
||||
| Camera sequence gaps | 0 | 0 | pass |
|
||||
| Failures | 0 | 0 | pass |
|
||||
| Session-end | required | seen | pass |
|
||||
| Commands/navigation authority | false | false | pass |
|
||||
|
||||
Все 18 acceptance checks прошли.
|
||||
|
||||
## 8. Производительность финального run
|
||||
|
||||
### 8.1 End-to-end
|
||||
|
||||
- Source span: 14.989973 s.
|
||||
- Measured run wall: 15.281886 s.
|
||||
- Detector: 151 frames, 9.881 FPS.
|
||||
- Semantic: 31 frames, 2.029 FPS.
|
||||
- Fused: 136 frames.
|
||||
- Source-paced factor: около 1.019× wall/source, то есть контур удержал темп источника.
|
||||
- Full orchestrator wall including isolated preflight, second model load, warm-up, run and publication: 113.669 s.
|
||||
|
||||
Большой orchestrator wall — cold-start overhead лабораторного запуска, а не latency активного perception loop. Для onboard-контура модели должны жить в постоянном worker process и не загружаться дважды перед каждой сессией.
|
||||
|
||||
### 8.2 Latency, ms
|
||||
|
||||
| Stage | mean | p50 | p95 | max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| fMP4 decode age | 8.003 | 6.428 | 15.150 | 41.297 |
|
||||
| Detector queue wait | 8.577 | 0.086 | 47.431 | 165.393 |
|
||||
| Detector | 25.607 | 23.899 | 36.631 | 43.492 |
|
||||
| Sensor wait | 10.075 | 0.033 | 90.136 | 110.995 |
|
||||
| KB4 projection | 0.538 | 0.372 | 0.838 | 12.403 |
|
||||
| Association/cuboid | 5.297 | 4.100 | 14.197 | 55.061 |
|
||||
| Clearance | 0.204 | 0.177 | 0.413 | 0.877 |
|
||||
| World-state projection | 0.154 | 0.131 | 0.358 | 0.748 |
|
||||
| End-to-end world-state age | 58.154 | 43.815 | 149.427 | 282.994 |
|
||||
|
||||
World-state health:
|
||||
|
||||
- healthy: 136 frames;
|
||||
- degraded: 13 frames;
|
||||
- stale: 2 frames;
|
||||
- unavailable: 0 frames.
|
||||
|
||||
Два max-latency spikes выше 200 ms не нарушили заданный p95 gate, но подтверждают, что результат пока не имеет safety authority.
|
||||
|
||||
### 8.3 Semantic timing, ms
|
||||
|
||||
| Stage | mean | p50 | p95 | max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Total processing | 135.524 | 126.185 | 147.835 | 396.316 |
|
||||
| Forward | 107.197 | 99.099 | 109.701 | 369.566 |
|
||||
| Completion age | 146.979 | 137.378 | 162.162 | 437.821 |
|
||||
|
||||
Semantic fresh status был доступен для 146/151 detector frames. Первые пять frames корректно помечены `semantic-unavailable`, а не замаскированы фиктивным результатом.
|
||||
|
||||
### 8.4 Sensor decode
|
||||
|
||||
- LiDAR decode mean/p95/max: 12.373 / 12.913 / 97.968 ms.
|
||||
- Pose decode mean/p95/max: 0.077 / 0.103 / 0.186 ms.
|
||||
- Synchronizer maximum stored depth: 32 point frames + 32 pose frames.
|
||||
- Media buffer maximum actual depth: 178,687 bytes из 8,388,608 bytes.
|
||||
- Camera metadata maximum depth: 2 из 16.
|
||||
|
||||
## 9. Functional output
|
||||
|
||||
- 1,188 detector/fusion object records рассмотрено за 151 frames.
|
||||
- 355 accepted amodal cuboid states опубликовано.
|
||||
- Frames с хотя бы одним accepted 3D object: 130/151.
|
||||
- Maximum simultaneous accepted objects: 7.
|
||||
- Accepted class states: 350 vehicle, 5 person.
|
||||
- Cuboids являются temporal object states, а не 355 уникальными физическими объектами.
|
||||
- Ground-truth quality в E15 не измерялась.
|
||||
|
||||
Основные rejection classes:
|
||||
|
||||
- fewer than 8 clustered points: 572;
|
||||
- no semantic/LiDAR support: 173;
|
||||
- amodal support coverage below threshold: 72;
|
||||
- amodal required size exceeds class bound: 7;
|
||||
- fewer than 4 clustered points: 6;
|
||||
- distance innovation: 3.
|
||||
|
||||
Эти отказы fail-closed: сомнительная геометрия не превращается в принятый 3D cuboid.
|
||||
|
||||
## 10. Hardware и storage
|
||||
|
||||
Accepted run:
|
||||
|
||||
- process peak RSS: 2,023.5 MiB;
|
||||
- process CUDA peak allocated: 2,107.9 MiB;
|
||||
- process CUDA peak reserved: 2,840 MiB;
|
||||
- total GPU memory used telemetry: около 13,298 MiB, включая соседний Triton contour;
|
||||
- GPU utilization mean/p95/max: 27.4% / 45% / 58%;
|
||||
- GPU memory-utilization mean/p95/max: 11.8% / 20% / 25%;
|
||||
- GPU power mean/p95/max: 140.5 / 144.65 / 145.41 W;
|
||||
- GPU temperature mean/p95/max: 44.6 / 47 / 48 °C.
|
||||
|
||||
Storage:
|
||||
|
||||
- hard free-space floor: 360 GiB;
|
||||
- observed final free space: около 389.34 GiB;
|
||||
- accepted result payload: около 1.56 MiB;
|
||||
- no per-frame PNG/JPEG output;
|
||||
- no recorded video copy in result;
|
||||
- disk `C:` не проверялся и не использовался;
|
||||
- persistent inputs, runtime и results находятся на `D:`.
|
||||
|
||||
## 11. Accepted artifacts
|
||||
|
||||
Local audit mirror:
|
||||
|
||||
`.runtime/compute-experiments/e15/results/e15-shadow-inference-f3f06e7c6b338add445f7ee4e4a81f831cd763a899ac2fbe29ba3476c4bdf0c8`
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `fusion-frames.jsonl` | 1,016,064 | `ae510afe881d7d6c5bc94520c5f87c385fdeb984bd30bd0cff70bd103f718963` |
|
||||
| `world-state.jsonl` | 568,384 | `579266c99ae1e65b0240e8c5c224beba4073d05c531c5a725f096e9bbf9a3c05` |
|
||||
| `semantic-frames.jsonl` | 13,955 | `21a8a659cc19e2acc9b971b93b746a4e26f8bb51829d1e1d13c4f42c1f79f1a5` |
|
||||
| `gpu-telemetry.jsonl` | 3,591 | `51024d0774dd7e648ca4ae0cba69cca4de70baf8e47c425fa78c6805c2824712` |
|
||||
| `run-report.json` | 13,230 | `f04fce80d9246d3c19f7368d6174787eb2fd73cdc7c51fa8d18555a0bfa0bf65` |
|
||||
| `result.json` | 6,384 | `5b933109eedba02483ad35c65221d344da19f7e2584bd700d9be7c6b2ecef995` |
|
||||
|
||||
Rejected Attempt A также сохранён неизменяемо:
|
||||
|
||||
`.runtime/compute-experiments/e15/results/e15-shadow-inference-396152240d878551ffe4913e03aee95be0b0ec572b69d76313edd73a7ee0cf7d`
|
||||
|
||||
## 12. Вывод
|
||||
|
||||
На текущем RTX 4090 доказан не просто offline pipeline, а bounded source-paced perception loop на 15-секундном recorded replay:
|
||||
|
||||
- camera 10 Hz;
|
||||
- detector около 10 Hz;
|
||||
- semantic около 2 Hz;
|
||||
- calibrated LiDAR/pose fusion;
|
||||
- diagnostic 3D cuboids и world-state;
|
||||
- p95 результата 149.4 ms;
|
||||
- нулевые transport/model queue drops;
|
||||
- 90.1% fused coverage;
|
||||
- никакой command authority.
|
||||
|
||||
Следующий правильный gate — не дальнейшее улучшение recorded-video качества, а тот же профиль на физическом K1 shadow stream. Параллельно нужно убрать лабораторный cold start, превратив model-loaded contour в постоянный worker service. Только после повторяемого physical live gate имеет смысл подключать live world-state/3D cuboids к Rerun UI и отдельно начинать forest-domain evaluation и safety policy.
|
||||
|
||||
## 13. Следующие действия
|
||||
|
||||
1. Поднять persistent model-loaded E15 worker на `D:` без двойной загрузки моделей на каждую сессию.
|
||||
2. Подключить физический K1 через уже существующий shadow ingress без command path.
|
||||
3. Повторить ≥15 s physical shadow gate с теми же thresholds и отдельным LAB E16 report.
|
||||
4. Добавить reconnect/worker-loss negative control: raw recorder обязан продолжить запись.
|
||||
5. Передавать accepted `world-state` и cuboids в live Rerun entities с source/result timestamps и health.
|
||||
6. После live transport qualification собирать forest-domain ground truth и считать 2D segmentation/object, distance и 3D cuboid quality отдельно от throughput.
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
# LAB E16 — Persistent AI worker and unified live perception publication
|
||||
|
||||
Date: 2026-07-22
|
||||
|
||||
Acceptance time: 2026-07-22 21:23 MSK / 2026-07-22T18:23:49Z
|
||||
|
||||
## Purpose
|
||||
|
||||
LAB E16 validates the infrastructure between the previously accepted E15 perception
|
||||
pipeline and one unified Mission Core Rerun scene. This is an infrastructure
|
||||
acceptance, not a physical K1 perception acceptance. No device, acquisition or camera
|
||||
session was active during this lab.
|
||||
|
||||
The target operator contract is one original-video timeline with independently
|
||||
switchable derived layers:
|
||||
|
||||
- tracked 2D detections with confidence and metric distance when fusion provides it;
|
||||
- semantic segmentation on the original camera image;
|
||||
- accepted, calibrated 3D cuboids in the LiDAR/world scene.
|
||||
|
||||
## Safety and authority
|
||||
|
||||
- The production Mission Core process at `127.0.0.1:8000` was not stopped or restarted.
|
||||
- UI and backend acceptance used an isolated shadow process at `127.0.0.1:8001`.
|
||||
- The AI worker ran only on the Windows worker's `D:` drive.
|
||||
- VPN, Hidemyname, Wi-Fi and host network configuration were not modified.
|
||||
- Worker authority remained `commands_enabled=false` and
|
||||
`navigation_or_safety_accepted=false`.
|
||||
- The result direction is diagnostic-only and contains no command endpoint.
|
||||
|
||||
## Immutable worker inputs
|
||||
|
||||
- Runner SHA-256:
|
||||
`f2f2889e07c609e3ba53b4504c3f2417f151fbb9920828f4680ea375a5d3010e`.
|
||||
- Persistent-service orchestrator SHA-256:
|
||||
`ed2dc35de423d0d9f8ac0501db283a10081e78b6535ebeb8d521a58ca6988b11`.
|
||||
- Persistent-run orchestrator SHA-256:
|
||||
`1c6af15736396ee0124d4aabe309b7bc24bb1425d88e24295c5675c686fc27bc`.
|
||||
- Worker package:
|
||||
`e15-worker-package-34fb22ce506aab6e3b6a0fd8058c868e24076a8cfc739b4299b2474f2cb92b99`.
|
||||
- Runner root on the worker:
|
||||
`D:\NDC_MISSIONCORE\runtime\derived\e16-runner-f2f2889e07c6-ed2dc35de423-1c6af1573639-34fb22ce506a`.
|
||||
- Worker package root:
|
||||
`D:\NDC_MISSIONCORE\runtime\inputs\e16\worker-packages\e15-worker-package-34fb22ce506aab6e3b6a0fd8058c868e24076a8cfc739b4299b2474f2cb92b99`.
|
||||
- Container: `mission-core-perception-worker`.
|
||||
- Pinned base image: `nvcr.io/nvidia/tritonserver:26.06-py3` with the E15 digest.
|
||||
|
||||
## Persistent model-loaded worker result
|
||||
|
||||
- GPU: NVIDIA GeForce RTX 4090.
|
||||
- Model load and warm-up: 38.296654 s.
|
||||
- Health: `ready`.
|
||||
- Models loaded: true.
|
||||
- Completed physical runs: 0.
|
||||
- Failed physical runs: 0.
|
||||
- D: free after startup: 418,065,694,720 bytes, approximately 389.354 GiB.
|
||||
- Guarded D: floor: 360 GiB plus a 512 MiB startup reserve.
|
||||
|
||||
The service binds its control HTTP endpoint only to `127.0.0.1` inside the worker
|
||||
container's shared Triton network namespace. It has no exposed LAN control port. A run
|
||||
request is single-flight, authenticated by the existing shadow token, limited to one
|
||||
direct child of the guarded D:-backed publication root and reuses already loaded models.
|
||||
|
||||
## Result transport contract
|
||||
|
||||
The authenticated K1 shadow WebSocket remains the sensor ingress and now has one
|
||||
bounded worker-to-Mission-Core diagnostic result direction.
|
||||
|
||||
- Result schema: `missioncore.live-perception-result-wire/v1`.
|
||||
- Result queue: latest-wins, capacity 2.
|
||||
- Maximum result payload: 2 MiB.
|
||||
- Maximum JSON header: 256 KiB.
|
||||
- Maximum original JPEG: 1 MiB.
|
||||
- Segmentation: fixed 800x600 uint8 mask compressed with zlib.
|
||||
- Maximum objects per frame: 128.
|
||||
- Payload SHA-256, lengths, geometry, timestamps and authority are validated before
|
||||
the frame is admitted to the visualization runtime.
|
||||
- Partial cuboids, non-finite geometry, invalid JPEG/mask payloads and any authority
|
||||
claim are rejected.
|
||||
|
||||
## Unified Rerun publication
|
||||
|
||||
Accepted live results use the same timeline and stable entities:
|
||||
|
||||
- `/perception/camera/image` — original worker-decoded camera frame;
|
||||
- `/perception/camera/detections` — 2D boxes, track id, class, confidence and distance;
|
||||
- `/perception/camera/segmentation` — semantic mask;
|
||||
- `/world/perception/boxes3d` — calibrated accepted cuboids.
|
||||
|
||||
The live blueprint switches between the normal 3D-only scene and a single horizontal
|
||||
original-video plus 3D-world composition. The three AI controls are independent; they
|
||||
are not mutually exclusive views. When dynamic perception is active, Rerun uses
|
||||
latest-at presentation so historical cuboids do not stack into a cloud of boxes.
|
||||
|
||||
## Validation
|
||||
|
||||
- Complete Python test suite: passed.
|
||||
- Focused live result, WebSocket return, visualization runtime and Rerun bridge tests:
|
||||
passed.
|
||||
- Ruff on the changed Python surface: passed.
|
||||
- Frontend TypeScript build: passed.
|
||||
- Frontend unit suite: 144/144 passed.
|
||||
- Production frontend build: passed.
|
||||
- Windows PowerShell parser accepted both service and run orchestrators.
|
||||
- In-container encode/decode probe returned a 600x800 segmentation mask successfully.
|
||||
- Shadow backend health at `127.0.0.1:8001`: healthy.
|
||||
- Production backend health at `127.0.0.1:8000`: healthy and uninterrupted.
|
||||
- Browser QA opened RAVNOVES00, reached Rerun ready state, enabled 2D,
|
||||
segmentation and 3D cuboids simultaneously, and reported zero browser errors.
|
||||
|
||||
## Disk handling
|
||||
|
||||
The earlier 418 MiB temporary recorded-perception RRD used for payload inspection was
|
||||
removed after validation. It was a regenerable temporary duplicate, not source evidence.
|
||||
No source recording or user data was deleted.
|
||||
|
||||
## Accepted conclusions
|
||||
|
||||
1. The models can remain loaded between runs; the previous double-load startup cost is
|
||||
removed from subsequent run requests.
|
||||
2. Mission Core has a bounded, authenticated, non-authoritative return path for live AI
|
||||
presentation.
|
||||
3. Original video, 2D detections, segmentation and 3D cuboids now share one Rerun
|
||||
timeline and one operator composition.
|
||||
4. Layer visibility is independently controlled and no longer modeled as separate
|
||||
perception streams.
|
||||
|
||||
## Explicitly not accepted yet
|
||||
|
||||
- Physical K1 shadow inference: no device session was active.
|
||||
- End-to-end detector, segmentation and fusion FPS through the new persistent service:
|
||||
requires the next physical 20-second K1 gate.
|
||||
- The new backend code is intentionally not active on production port 8000 yet because
|
||||
that process was not restarted. Port 8001 is the accepted shadow build.
|
||||
- 3D semantic point coloring is not yet returned by the live result frame; E16 covers
|
||||
camera segmentation and 3D cuboids.
|
||||
- The recorded full-session perception RRD remains a large monolith and is not the
|
||||
target real-time transport.
|
||||
- None of the outputs are ground truth or safety/navigation accepted.
|
||||
|
||||
## Next gate
|
||||
|
||||
1. Connect K1 and establish a normal Mission Core device/acquisition/camera session.
|
||||
2. Run one 20-second persistent physical shadow request without command authority.
|
||||
3. Record detector FPS, semantic FPS, fusion fraction, result age, result payload rate,
|
||||
GPU telemetry, queue drops and D: free space.
|
||||
4. Verify the same four entities visually in the live Rerun scene.
|
||||
5. Add bounded semantic LiDAR point coloring, then repeat as LAB E17.
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
# LAB E3 — K1 rectified segmentation baseline
|
||||
|
||||
Date: 2026-07-20
|
||||
Completed at: 2026-07-20T18:57:23.215Z
|
||||
State: completed, model draft, not ground truth
|
||||
Ops card: MISSIONCOR-17
|
||||
|
||||
## Objective
|
||||
|
||||
Compare an adult Cityscapes semantic model on the same immutable XGRIDS K1
|
||||
evaluation pack under three input profiles:
|
||||
|
||||
1. valid-FOV fisheye image;
|
||||
2. valid-FOV fisheye image with CLAHE;
|
||||
3. factory-calibrated KB4 five-view perspective rectification with CLAHE and
|
||||
fusion back into the original fisheye coordinate system.
|
||||
|
||||
The experiment answers whether calibration-derived rectification is technically
|
||||
valid, how much it costs, and whether it is a credible next baseline. It does not
|
||||
measure accuracy because no reviewed ground truth exists yet.
|
||||
|
||||
## Immutable inputs and provenance
|
||||
|
||||
- Evaluation pack:
|
||||
`evaluation-pack-7a983bba75d46c7c260252cb2d461e1384dcb92cda9e164397e841e6ebb37789`
|
||||
- Session: `20260720T065719Z_viewer_live`
|
||||
- Frames: 64, image IDs 1–64, resolution 800×600
|
||||
- Source: `sensor.camera.right`
|
||||
- Factory calibration slot: `camera_1`
|
||||
- Factory calibration content SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- Valid-FOV generation:
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`
|
||||
- Valid pixels per frame: 270,606
|
||||
- LAB E2 comparison draft:
|
||||
`evaluation-prelabels-4ba26bbf6eb8a49631f5caf984267e0445958540aeda2b5b0d82ca6440835cf1`
|
||||
|
||||
The LAB E2 draft has a legacy provenance inconsistency. Its declared identity
|
||||
SHA-256 is `4ba26b…`, while a fresh canonical hash of the stored identity document
|
||||
is `33292c85eb8281d68c1b444baaf0d7931c7aa84938ac1d329b89e93f3b525099`.
|
||||
LAB E3 did not alter LAB E2. It independently verified the evaluation-pack
|
||||
binding, the declared result/directory relationship, and the size and SHA-256 of
|
||||
every one of the 64 consumed semantic artifacts. Both hashes and
|
||||
`e2_prelabel_identity_consistent=false` are preserved in the LAB E3 identity.
|
||||
|
||||
## Calibration and preprocessing configuration
|
||||
|
||||
Camera-1 intrinsics (`fx, fy, cx, cy`):
|
||||
|
||||
```text
|
||||
194.59817287616025
|
||||
194.57531427932872
|
||||
396.31861150187996
|
||||
301.49644357408005
|
||||
```
|
||||
|
||||
Kannala–Brandt KB4 coefficients:
|
||||
|
||||
```text
|
||||
-0.023164451386679667
|
||||
-0.0014974198594105452
|
||||
-0.001039213149441563
|
||||
-0.000035237331915978814
|
||||
```
|
||||
|
||||
Rectification profile:
|
||||
|
||||
- projection: five perspective gnomonic views;
|
||||
- views: front, left, right, up, down;
|
||||
- yaw/pitch: `0/0`, `-90/0`, `90/0`, `0/90`, `0/-90` degrees;
|
||||
- tile size: 768×768;
|
||||
- horizontal and vertical FOV: 100 degrees;
|
||||
- RGB interpolation: OpenCV linear remap;
|
||||
- fused-label sampling: nearest pixel;
|
||||
- overlap winner: maximum optical-axis cosine;
|
||||
- contrast: CLAHE on LAB luminance, clip limit 2.0, grid 8×8.
|
||||
|
||||
Measured geometry coverage:
|
||||
|
||||
- valid pixels covered: 270,606 of 270,606;
|
||||
- coverage: 100%;
|
||||
- views covering one valid pixel: minimum 1, maximum 3, mean 1.190099259;
|
||||
- uncovered valid pixels: 0.
|
||||
|
||||
## Model and software identity
|
||||
|
||||
- Model: `tue-mps/cityscapes_semantic_eomt_large_1024`
|
||||
- Architecture: `EomtForUniversalSegmentation`
|
||||
- Revision: `8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f`
|
||||
- Precision: FP16 autocast
|
||||
- Batch size: 1
|
||||
- Model weights: 1,276,175,488 bytes, SHA-256
|
||||
`c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`
|
||||
- Config SHA-256:
|
||||
`7f4aa94fa4e43c0dbd79a5420edb511120aef62bd82bfbcbcece79948286a650`
|
||||
- Preprocessor SHA-256:
|
||||
`97e2fbf7f0bdba2cfc90251c5133bae9c27ddc9c4410509f40670be2332854e7`
|
||||
- OpenCV distribution: `opencv-python-headless==4.13.0.92`
|
||||
- OpenCV runtime module: `4.13.0`
|
||||
- Transformers: `4.57.6`
|
||||
- PyTorch: `2.13.0+cu130`
|
||||
- CUDA runtime: `13.0`
|
||||
- Container: existing `nvcr.io/nvidia/tritonserver:26.06-py3`; no image pull
|
||||
- Profile SHA-256:
|
||||
`ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`
|
||||
- Dependency identity SHA-256:
|
||||
`4eb1f8d33236806e74f9e5bb96b7dce2ac37623dc39b2184be2aa8d7d00e983e`
|
||||
- Final runner SHA-256:
|
||||
`01881862d4eaa218955f776a948124bf19c34be2b5ec282115daeacb15c53ae6`
|
||||
|
||||
The Cityscapes labels were mapped into the existing Mission Core 0–15
|
||||
perception taxonomy. Outputs outside the admitted K1 valid FOV were forced to
|
||||
category 0.
|
||||
|
||||
## Execution topology
|
||||
|
||||
The comparison was deliberately sequential so each profile has isolated timing:
|
||||
|
||||
```text
|
||||
frame
|
||||
-> EoMT on fisheye mask
|
||||
-> CLAHE -> EoMT on fisheye mask
|
||||
-> KB4 remap of five views -> CLAHE -> five sequential EoMT calls
|
||||
-> nearest-label fusion into the raw fisheye coordinates
|
||||
-> masks, preview, metrics, and telemetry
|
||||
```
|
||||
|
||||
There were seven model evaluations per frame and 448 evaluations for the full
|
||||
pack. No software GPU-utilization or power cap was applied. The experiment did
|
||||
not batch or parallelize the five rectified views; that is a future optimization,
|
||||
not part of the present baseline.
|
||||
|
||||
## Full-run results
|
||||
|
||||
Immutable result:
|
||||
`e3-segmentation-01bd497c44c2b940add145ec784d3418010327bce0baddd4420b0925317e8a16`
|
||||
|
||||
| Variant | Mean end-to-end/frame | Mean forward | Approx. standalone throughput | Mean change vs E2 draft |
|
||||
|---|---:|---:|---:|---:|
|
||||
| EoMT fisheye mask | 121.02 ms | 98.64 ms | 8.26 FPS | 16.44% pixels |
|
||||
| EoMT fisheye + CLAHE | 127.41 ms | 99.10 ms | 7.85 FPS | 16.51% pixels |
|
||||
| EoMT KB4 cubemap5 + CLAHE | 341.97 ms | 267.39 ms across 5 views | 2.92 FPS | 18.13% pixels |
|
||||
|
||||
Additional timing facts:
|
||||
|
||||
- full comparison wall time: 46.170785 seconds;
|
||||
- average full comparison time: 721.42 ms/frame including all three variants,
|
||||
preview/mask encoding, metrics, and telemetry;
|
||||
- five-view rectification preprocessing: 22.60 ms/frame mean;
|
||||
- five-view label fusion: 3.03 ms/frame mean;
|
||||
- one rectified tile forward: 53.48 ms mean, 65.85 ms p95;
|
||||
- raw-vs-cubemap pixel disagreement: 8.24% mean, 5.80% median, 24.66% p95,
|
||||
36.76% maximum.
|
||||
|
||||
The largest raw/cubemap difference occurs at image 59 / frame 4224. The
|
||||
near-structure temporal clip (images 60–63, frames 4433–4436) contributes four
|
||||
of the next five largest differences. This is the correct area to prioritize in
|
||||
human review: extreme distortion and nearby planar structures are precisely
|
||||
where projection choice changes the model most.
|
||||
|
||||
## GPU, CPU, memory, and disk
|
||||
|
||||
Worker GPU: NVIDIA GeForce RTX 4090, compute capability 8.9.
|
||||
|
||||
- process CUDA peak allocated: 2,099.8 MiB;
|
||||
- process CUDA peak reserved: 2,840.0 MiB;
|
||||
- total board memory used, including pre-existing services: 13,406 MiB max,
|
||||
13,396.9 MiB mean;
|
||||
- GPU utilization: 72.34% mean, 91% max;
|
||||
- GPU memory-controller utilization: 27.38% mean, 41% max;
|
||||
- GPU power: 288.88 W mean, 334.09 W max;
|
||||
- GPU temperature: 55.94°C mean, 61°C max;
|
||||
- process peak RSS: 2,097.14 MiB;
|
||||
- GPU telemetry samples: 47 at one-second intervals.
|
||||
|
||||
All task-controlled persistent artifacts are on `D:\NDC_MISSIONCORE`:
|
||||
|
||||
| Artifact group | Bytes | MiB |
|
||||
|---|---:|---:|
|
||||
| Pinned E3 model cache | 1,276,294,838 | 1,217.17 |
|
||||
| Pinned OpenCV environment | 211,776,082 | 201.97 |
|
||||
| Four-frame pilot | 2,040,795 | 1.95 |
|
||||
| Full 64-frame result | 7,924,484 | 7.56 |
|
||||
| Total | 1,498,036,199 | 1,428.64 |
|
||||
|
||||
Final D: free space was 414,903,472,128 bytes (386.409 GiB), 26.409 GiB above
|
||||
the enforced 360 GiB floor. No task-controlled C: path was created or mounted.
|
||||
The report does not claim to measure incidental internal writes by Windows or an
|
||||
already configured Docker Desktop engine.
|
||||
|
||||
## Visual findings
|
||||
|
||||
1. The factory KB4 geometry is usable now. The five views are upright, have the
|
||||
expected content, and fuse back without missing valid pixels.
|
||||
2. EoMT produces visibly cleaner road, grass, building, person, and vehicle
|
||||
regions than the LAB E2 BEiT draft on the inspected samples.
|
||||
3. CLAHE changes the global result very little while adding about 6.4 ms/frame.
|
||||
It is not justified as the default from this unreviewed proxy alone.
|
||||
4. Cubemap5 often improves separation of people, cars, and rectified planar
|
||||
surfaces, especially where raw fisheye distortion is severe.
|
||||
5. Hard winner-take-all fusion creates local seam/checker artifacts, most visible
|
||||
near the lower rim, the sun/sky, and close structures. Cubemap5 should not be
|
||||
promoted to the default without seam-aware fusion and reviewed accuracy.
|
||||
6. Cityscapes has no explicit dirt, animal, or general other-background output in
|
||||
this mapping. That limits this model as a complete forest/off-road taxonomy.
|
||||
|
||||
## Integrity and acceptance status
|
||||
|
||||
- 211 of 211 listed output artifacts were rehashed successfully after transfer.
|
||||
- 192 of 192 semantic masks were checked.
|
||||
- Every semantic value was within categories 0–15.
|
||||
- Every pixel outside the valid-FOV mask was category 0.
|
||||
- Four-frame pilot and full-run outputs are immutable and separately identified.
|
||||
- Outputs remain `ground_truth=false`.
|
||||
- No mIoU, AP, distance accuracy, 3D geometry, tracking, or safety claim is made.
|
||||
|
||||
## Engineering gates encountered
|
||||
|
||||
The following preflight failures happened before a result was accepted and are
|
||||
preserved as part of the laboratory history:
|
||||
|
||||
- Git revision and SHA-256 were initially treated as the same hash shape;
|
||||
- the OpenCV package version `4.13.0.92` differs from runtime `cv2` version
|
||||
`4.13.0`;
|
||||
- Hugging Face snapshot files are symlinks into a content-addressed blob store;
|
||||
- the initially recorded weight SHA was replaced by the independently measured
|
||||
blob SHA-256 `c265…`;
|
||||
- EoMT processor output includes a non-tensor `task_inputs` field;
|
||||
- LAB E2 contains the identity inconsistency documented above;
|
||||
- a PowerShell full-run expected-frame lookup initially read the wrong JSON
|
||||
level. That technical run was stopped, its exact transient container was
|
||||
removed, its staging was cleaned, and no result was published.
|
||||
|
||||
These were fail-closed contract checks, not model failures.
|
||||
|
||||
## Decision and next gate
|
||||
|
||||
The current evidence supports this decision:
|
||||
|
||||
- keep plain EoMT fisheye-mask as the speed/quality control baseline;
|
||||
- keep cubemap5 as the calibration-derived challenger;
|
||||
- do not make CLAHE the default yet;
|
||||
- do not select a production winner from proxy disagreement or visual inspection;
|
||||
- review the high-disagreement and temporal frames in CVAT, export reviewed masks,
|
||||
and calculate class-wise IoU, boundary IoU, temporal stability, and object-class
|
||||
recall before changing the default pipeline;
|
||||
- if cubemap5 survives reviewed accuracy, replace hard view selection with
|
||||
overlap-aware/logit fusion and then test batched five-view inference.
|
||||
|
||||
3D cuboids, LiDAR association, distance accuracy, and point-cloud segmentation
|
||||
remain outside LAB E3 and should use the reviewed 2D baseline as an input to the
|
||||
next dedicated experiments rather than being mixed into this result.
|
||||
|
||||
## CVAT review handoff
|
||||
|
||||
The control and challenger were converted into deterministic CVAT Segmentation
|
||||
Mask 1.1 packages and imported into the existing D-hosted CVAT v2.70.0 instance.
|
||||
The original 64-frame image archive is reused and was not duplicated.
|
||||
|
||||
- Workspace:
|
||||
`e3-cvat-review-ca599521e345446ca8e9af5a9013062099278e7317f83ff89740c6b092ddc52f`
|
||||
- Workspace manifest SHA-256:
|
||||
`5e1b318d31431c2081c169235ec877693591ef2b05bbebab61c72061bc1621d9`
|
||||
- CVAT import report SHA-256:
|
||||
`fe0933efc3a5bb1bcb7b995bb1241b308e7a860113cd7a97351aedfbd43c9b64`
|
||||
- [Task 3 — EoMT fisheye control](http://localhost:18080/tasks/3): 64 frames,
|
||||
5,284 imported shapes.
|
||||
- [Task 4 — EoMT KB4 cubemap5 challenger](http://localhost:18080/tasks/4): 64
|
||||
frames, 5,762 imported shapes.
|
||||
- Both task pages returned HTTP 200 through the existing SSH tunnel.
|
||||
- Both tasks remain model drafts and `ground_truth=false`.
|
||||
- Review priority by measured raw/cubemap disagreement: image IDs
|
||||
`59, 63, 62, 61, 60, 64, 50, 30, 17, 29`.
|
||||
- Review bundle size on D: 1,390,051 bytes.
|
||||
- Post-import D: free space: 414,762,061,824 bytes (386.277 GiB), 26.277 GiB
|
||||
above the enforced floor.
|
||||
|
||||
The required human sequence is still two passes: correction/completeness first,
|
||||
then independent consistency review. Importing the drafts does not complete that
|
||||
gate and does not convert either task into ground truth.
|
||||
|
||||
## Durable paths
|
||||
|
||||
Worker full result:
|
||||
|
||||
```text
|
||||
D:\NDC_MISSIONCORE\runtime\derived\e3-segmentation\e3-segmentation-01bd497c44c2b940add145ec784d3418010327bce0baddd4420b0925317e8a16
|
||||
```
|
||||
|
||||
Local evidence copy:
|
||||
|
||||
```text
|
||||
.runtime/compute-experiments/e3/full/e3-segmentation-01bd497c44c2b940add145ec784d3418010327bce0baddd4420b0925317e8a16
|
||||
```
|
||||
|
||||
The `result.json`, `run-report.json`, 16 comparison previews, rectification
|
||||
atlas, 192 semantic masks, and GPU telemetry are contained in that result.
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
# LAB E4 — Full-session EoMT segmentation playback
|
||||
|
||||
Date: 2026-07-21
|
||||
Completed at: 2026-07-21T07:21:16.791Z
|
||||
State: completed and published, model output, not ground truth
|
||||
Ops card: MISSIONCOR-18
|
||||
|
||||
## Objective
|
||||
|
||||
Turn the plain EoMT valid-FOV configuration selected in LAB E3 into the first
|
||||
complete, watchable semantic-segmentation result for a saved Mission Core
|
||||
session. The acceptance target was deliberately narrow and observable:
|
||||
|
||||
1. process every recorded camera frame, without sampling;
|
||||
2. preserve the exact saved-session timeline;
|
||||
3. publish an H.264 semantic-overlay video and per-frame semantic masks through
|
||||
the existing recorded-perception contract;
|
||||
4. make that video selectable and playable in the RAVNOVES00 saved-session UI;
|
||||
5. measure full inference, publication, GPU, memory, power, temperature and disk
|
||||
behavior on the connected AI worker;
|
||||
6. keep all task-controlled worker data on `D:\NDC_MISSIONCORE` and enforce a
|
||||
360 GiB free-space floor.
|
||||
|
||||
LAB E4 is semantic-only. It does not claim instance detection, tracking, 3D
|
||||
cuboids, LiDAR association, metric distance, point-cloud segmentation or
|
||||
safety-grade perception.
|
||||
|
||||
## Immutable input and provenance
|
||||
|
||||
- UI session alias: `RAVNOVES00`
|
||||
- Session ID: `20260720T065719Z_viewer_live`
|
||||
- Compute job: `recorded-camera-602ac89026ed12978619801d`
|
||||
- Source: `sensor.camera.right`
|
||||
- Factory calibration slot: `camera_1`
|
||||
- Input SHA-256:
|
||||
`602ac89026ed12978619801d4edea0cae24b5cc3afabd9f7af2858de6505a20e`
|
||||
- Factory calibration content SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- Codec epoch: 1
|
||||
- Resolution: 800x600
|
||||
- Segments/frames admitted: 4,489/4,489
|
||||
- Timeline: 35.421857292–484.144857292 session seconds
|
||||
- Media duration: 448.723 seconds
|
||||
- Approximate source rate: 10.0037 FPS
|
||||
- Frame timeline SHA-256:
|
||||
`16098d7606e5462f1d6ccbc1b6e99158f68e2b2ca85ea11794799ef709b61c61`
|
||||
|
||||
The valid image circle is the immutable LAB E1 artifact
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`.
|
||||
Its mask SHA-256 is
|
||||
`a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`.
|
||||
It admits 270,606 of 480,000 pixels. The mask is loaded once and reused; it is
|
||||
not recomputed from every frame.
|
||||
|
||||
## Accepted configuration
|
||||
|
||||
LAB E4 deliberately promotes the LAB E3 speed/quality control, not the more
|
||||
expensive five-view challenger:
|
||||
|
||||
- pipeline: `recorded-semantic-eomt-fisheye-mask/v1`;
|
||||
- model: `tue-mps/cityscapes_semantic_eomt_large_1024`;
|
||||
- architecture: `EomtForUniversalSegmentation`;
|
||||
- pinned revision:
|
||||
`8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f`;
|
||||
- model weights: 1,276,175,488 bytes, SHA-256
|
||||
`c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`;
|
||||
- config SHA-256:
|
||||
`7f4aa94fa4e43c0dbd79a5420edb511120aef62bd82bfbcbcece79948286a650`;
|
||||
- preprocessor SHA-256:
|
||||
`97e2fbf7f0bdba2cfc90251c5133bae9c27ddc9c4410509f40670be2332854e7`;
|
||||
- precision: FP16 autocast;
|
||||
- batch size: 1;
|
||||
- frame policy: all frames, no sampling;
|
||||
- semantic-overlay alpha: 0.48;
|
||||
- CLAHE: disabled;
|
||||
- KB4 cubemap rectification: disabled;
|
||||
- instance branch: disabled;
|
||||
- container: existing `nvcr.io/nvidia/tritonserver:26.06-py3`;
|
||||
- PyTorch: `2.13.0+cu130`;
|
||||
- CUDA runtime: `13.0`;
|
||||
- Transformers: `4.57.6`;
|
||||
- NumPy: `1.26.4`;
|
||||
- Pillow: `12.3.0`.
|
||||
|
||||
Producer identities:
|
||||
|
||||
- runner SHA-256:
|
||||
`651e8e06c3912dffb036b7fd08f2c0623f7563d8306cc7aee05db562798518f4`;
|
||||
- orchestrator SHA-256:
|
||||
`c1c16fc351e7c8c4806fe2fd17a56e659495d843db5ff3e9ac3699a8d9722640`;
|
||||
- profile SHA-256:
|
||||
`ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`;
|
||||
- dependency identity SHA-256:
|
||||
`4eb1f8d33236806e74f9e5bb96b7dce2ac37623dc39b2184be2aa8d7d00e983e`.
|
||||
|
||||
The Cityscapes outputs are mapped into the existing Mission Core 0–15
|
||||
robotics taxonomy. Pixels outside the admitted FOV are category 0. Cityscapes
|
||||
does not provide the required forest-specific `ground_dirt`, `animal` and
|
||||
general `other_background` classes, so those mapped totals are zero. This is a
|
||||
known taxonomy limitation, not evidence that those contents are absent.
|
||||
|
||||
## Execution topology
|
||||
|
||||
The work was sequential at frame level:
|
||||
|
||||
```text
|
||||
recorded fMP4 epoch
|
||||
-> exact RGB frame reconstruction
|
||||
-> fixed calibration-bound valid-FOV fill
|
||||
-> EoMT semantic forward pass on RTX 4090
|
||||
-> resize/map semantic labels into Mission Core taxonomy
|
||||
-> force excluded FOV pixels to category 0
|
||||
-> write indexed semantic PNG and 48%-alpha overlay PNG
|
||||
-> append timestamped frame metadata
|
||||
-> encode overlays as H.264 MP4
|
||||
-> archive semantic masks
|
||||
-> hash, validate and publish result
|
||||
```
|
||||
|
||||
The input stream and model loop were not split across parallel jobs. Batch size
|
||||
was one. GPU work is internally asynchronous where PyTorch/CUDA permits, but
|
||||
the next frame was not admitted until the current frame's semantic result and
|
||||
artifacts had been produced. The final video was encoded on NVENC after the
|
||||
overlay sequence was complete.
|
||||
|
||||
No software power, utilization or memory cap was applied. The main deliberate
|
||||
limits were batch size one, one model, a 360 GiB D: free-space floor and a
|
||||
conservative preflight working-set reserve.
|
||||
|
||||
## Pilot gate
|
||||
|
||||
An eight-frame non-publishable pilot ran first under
|
||||
`pilot-8-4038b699764048579f9fb7c6886a0a10`.
|
||||
|
||||
- frames: 8/8;
|
||||
- inference wall: 2.381609 seconds;
|
||||
- throughput including frame artifacts: 3.359074 FPS;
|
||||
- visual check: valid-FOV alignment, road, vegetation, vehicles, person and
|
||||
building overlays were usable;
|
||||
- publication: prohibited by design.
|
||||
|
||||
The pilot exposed a report-only PowerShell parsing defect in a free-space
|
||||
measurement: status text and the numeric value reached the same output stream.
|
||||
The full-run orchestrator separated them before execution. The pilot data was
|
||||
retained as evidence; no pilot result entered the saved-session result store.
|
||||
|
||||
## Full-run result
|
||||
|
||||
Published result:
|
||||
`result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30`.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Frames expected / processed | 4,489 / 4,489 |
|
||||
| Frames failed / skipped | 0 / 0 |
|
||||
| Inference wall | 1,447.565362 s (24:07.565) |
|
||||
| Inference throughput | 3.101069 FPS |
|
||||
| Extract/reconstruction phase | 31.837595 s |
|
||||
| H.264 encoding | 3.374570 s |
|
||||
| Mask archive | 1.332036 s |
|
||||
| Full worker wall | 1,668.259063 s (27:48.259) |
|
||||
| End-to-end throughput | 2.690829 FPS |
|
||||
|
||||
The source plays at approximately 10.0037 FPS. This offline E4 path is therefore
|
||||
about 3.72 times slower than source real time. It is a complete playback
|
||||
baseline, not yet a live-stream configuration.
|
||||
|
||||
The earlier LAB E0 full path achieved 1.678305 inference FPS and 1.593908
|
||||
end-to-end FPS while executing both Mask R-CNN and BEiT. E4 is approximately
|
||||
1.85 times faster on the measured inference loop and 1.69 times faster overall,
|
||||
but the comparison is not like-for-like: E4 is semantic-only and intentionally
|
||||
disables the instance branch.
|
||||
|
||||
### Per-frame latency
|
||||
|
||||
| Stage | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| End to end | 322.458 ms | 320.654 ms | 348.467 ms | 441.481 ms |
|
||||
| Model forward | 141.142 ms | 140.483 ms | 153.122 ms | 185.848 ms |
|
||||
| Artifact write | 99.477 ms | 98.231 ms | 113.678 ms | 155.797 ms |
|
||||
| Image decode | 30.677 ms | 28.915 ms | 42.952 ms | 82.429 ms |
|
||||
| Processor | 10.656 ms | 9.597 ms | 15.529 ms | 85.791 ms |
|
||||
| Host to device | 7.737 ms | 7.515 ms | 10.726 ms | 21.986 ms |
|
||||
| Model postprocess | 10.214 ms | 10.421 ms | 14.078 ms | 25.200 ms |
|
||||
| Overlay | 18.712 ms | 17.595 ms | 25.332 ms | 42.439 ms |
|
||||
| Valid-FOV fill | 1.952 ms | 1.834 ms | 2.582 ms | 7.701 ms |
|
||||
|
||||
The measured stage means account for the frame loop. Approximately 184.15
|
||||
seconds of the full worker wall remain outside extract, inference, encode and
|
||||
archive. This includes full input/model hashing, model load, container/preflight
|
||||
work, exact stream setup, result finalization, output hashing and validation.
|
||||
|
||||
## GPU, process and neighboring services
|
||||
|
||||
Worker GPU: NVIDIA GeForce RTX 4090, compute capability 8.9.
|
||||
|
||||
- one-second GPU samples: 1,448;
|
||||
- GPU utilization: 73.05% mean, 73% p50, 89% p95, 95% max;
|
||||
- board memory used, including pre-existing services: 13,397.65 MiB mean,
|
||||
13,409 MiB max;
|
||||
- GPU memory-controller utilization: 21.41% mean, 32% p95, 35% max;
|
||||
- power: 234.24 W mean, 232.91 W p50, 247.15 W p95, 253.23 W max;
|
||||
- temperature: 52.94 C mean, 53 C p50, 57 C p95, 58 C max;
|
||||
- process CUDA peak allocated: 2,099.8 MiB;
|
||||
- process CUDA peak reserved: 2,840.0 MiB;
|
||||
- process peak RSS: 2,009.332 MiB.
|
||||
|
||||
The board-memory figure is not the E4 process allocation. It is the total
|
||||
device usage observed by `nvidia-smi`, including already running services. The
|
||||
per-process CUDA peaks above are the relevant E4 allocation measurements.
|
||||
`mission-core-triton`, `sentinel-frigate` and `sentinel-ollama` remained up.
|
||||
|
||||
## Disk policy and measured usage
|
||||
|
||||
The orchestrator admitted only explicit paths rooted at
|
||||
`D:\NDC_MISSIONCORE`. It performed a conservative space preflight, sampled free
|
||||
space every 100 frames and failed closed if the floor would be crossed.
|
||||
|
||||
| Disk point | Free bytes | Free GiB |
|
||||
|---|---:|---:|
|
||||
| Before run | 410,711,453,696 | 382.505 |
|
||||
| After input extraction | 407,553,695,744 | 379.564 |
|
||||
| After inference | 404,899,336,192 | 377.092 |
|
||||
| After final artifacts | 404,796,903,424 | 376.996 |
|
||||
| After task temporary cleanup | 407,372,025,856 | 379.395 |
|
||||
| Enforced floor | 386,547,056,640 | 360.000 |
|
||||
|
||||
The conservative working-set reserve was 18,462,883,615 bytes (17.195 GiB).
|
||||
The floor was never crossed. Final headroom above the floor was approximately
|
||||
19.395 GiB. No task-controlled C: path was used, created or mounted. This does
|
||||
not claim to observe incidental internal writes by Windows or the pre-existing
|
||||
Docker Desktop engine.
|
||||
|
||||
Only exact E4 temporary extraction, mask and overlay work directories were
|
||||
removed after successful publication. Immutable result artifacts, reports,
|
||||
telemetry, the pilot and runner identities remain recoverable on D:.
|
||||
|
||||
## Published artifacts and integrity
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `perception.mp4` | 75,353,780 | `6e58cf72d9571ebbb003a814132b44907b7c0e31d18b5449cce330afcad29829` |
|
||||
| `masks.tar.gz` | 39,812,627 | `ef291034e51c5c719dcc1c0902bae1e3daedf919d7b55a21fcb0a31af298071f` |
|
||||
| `frames.jsonl` | 3,927,616 | `4780339f5ef94e1f58922dfe2df67ecdf983c4b4e030761f4a990f65b511d5cf` |
|
||||
| `gpu-telemetry.jsonl` | 325,407 | `c6a4db1a8ea01d1ca66aded4bcd990ed91fc015930113869b20f024632a2aacf` |
|
||||
| `run-report.json` | 7,894 | `8ee04bf2ed8e563641dc1dacd320aee6fc8cd4077a43fab564033a9c29cbaa07` |
|
||||
|
||||
The published local result occupies approximately 123 MiB. Independent checks
|
||||
confirmed:
|
||||
|
||||
- all five artifact size/digest pairs match `result.json`;
|
||||
- `frames.jsonl` contains exactly 4,489 ordered timestamp rows;
|
||||
- the mask archive contains exactly 4,489 indexed masks;
|
||||
- FFprobe reports H.264, 800x600, 448.723 seconds and exactly 4,489 frames;
|
||||
- the complete recorded-perception v2 result validates against its job, input,
|
||||
session, source, timestamp bounds and `camera_1` calibration binding;
|
||||
- the result store selects this result as the latest admitted generation for
|
||||
`recorded.perception.right`.
|
||||
|
||||
Durable local result root:
|
||||
|
||||
```text
|
||||
.runtime/compute-results/recorded-camera-602ac89026ed12978619801d/
|
||||
result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30/
|
||||
```
|
||||
|
||||
Worker evidence root:
|
||||
|
||||
```text
|
||||
D:\NDC_MISSIONCORE\runtime\derived\
|
||||
result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30
|
||||
```
|
||||
|
||||
## Mission Core publication and UI proof
|
||||
|
||||
The saved-session replay API returned schema
|
||||
`missioncore.observation-session-replay/v2` with source
|
||||
`recorded.perception.right`, label `Сегментация · камера right`, the exact result
|
||||
ID and the MP4 generation SHA above. The media manifest validated as
|
||||
`missioncore.observation-recorded-media/v3`. A byte-range request returned HTTP
|
||||
206 with `Content-Range: bytes 0-1023/75353780`.
|
||||
|
||||
The final acceptance was performed in the real Mission Core browser UI:
|
||||
|
||||
1. open **Сохранённые сессии**;
|
||||
2. select **RAVNOVES00**;
|
||||
3. open the scene source picker;
|
||||
4. select **Сегментация · камера right**;
|
||||
5. press **Воспроизвести**.
|
||||
|
||||
The visible video element loaded the exact new result, reported 800x600,
|
||||
duration 448.723 seconds, `readyState=4` and no media error. Its current time
|
||||
advanced from 128.522 to 130.282 seconds during a timed browser check, proving
|
||||
that the result is not merely discoverable but actually plays.
|
||||
|
||||
## Visual findings
|
||||
|
||||
Start, middle and end frames were inspected from the final encoded video.
|
||||
|
||||
1. The fixed valid-FOV circle remains aligned across the complete recording;
|
||||
excluded lens pixels do not generate semantic clutter.
|
||||
2. Road, sidewalk, sky, buildings and vegetation form spatially coherent
|
||||
regions at the inspected start/middle/end points.
|
||||
3. Cars and people are visibly segmented when present, but this is semantic
|
||||
class color, not a persistent object identity.
|
||||
4. The output is substantially more usable than the original E0 visual result
|
||||
for playback, while still showing expected Cityscapes/domain artifacts on
|
||||
close structures, thin boundaries and forest/off-road content.
|
||||
5. No visual inspection can substitute for the pending human-reviewed E2
|
||||
accuracy set. The result remains `ground_truth=false`.
|
||||
|
||||
Final contact sheet:
|
||||
`.runtime/compute-experiments/e4/full-preview/start-mid-end.png`, SHA-256
|
||||
`a16f78732346db4b476b7d66dd140857d92eb1f11add5826c49e4dddf0ac1d5f`.
|
||||
|
||||
## Acceptance status and limitations
|
||||
|
||||
Accepted:
|
||||
|
||||
- complete no-sampling processing of the chosen saved session;
|
||||
- zero failed/skipped frames;
|
||||
- immutable semantic masks and frame timestamps;
|
||||
- exact H.264 playback on the original session timeline;
|
||||
- source discovery, manifest and HTTP range transport;
|
||||
- real UI selection and playback;
|
||||
- measured worker resource and disk behavior;
|
||||
- D-only task-controlled worker storage with enforced floor.
|
||||
|
||||
Not accepted or not attempted:
|
||||
|
||||
- live real-time throughput;
|
||||
- instance masks, object IDs or tracking;
|
||||
- 2D detector boxes from an E4 model branch;
|
||||
- 3D cuboids or point-cloud semantic labels;
|
||||
- LiDAR-camera association and distance-to-object accuracy;
|
||||
- forest/off-road model fitness;
|
||||
- mIoU, boundary IoU, AP or safety metrics;
|
||||
- production or safety qualification.
|
||||
|
||||
## Decision and next gate
|
||||
|
||||
E4 closes the first full-session semantic playback milestone. Plain EoMT with
|
||||
the immutable valid-FOV mask is now the operational reference for recorded
|
||||
video: reproducible, watchable and measured.
|
||||
|
||||
The next work should not be another blind full-epoch run. It should proceed in
|
||||
two bounded tracks:
|
||||
|
||||
1. finish human review of the E2 evaluation pack and measure semantic accuracy,
|
||||
especially people, vehicles, road/free-space boundaries and near distorted
|
||||
structures; use those metrics to decide whether to keep the raw fisheye
|
||||
baseline, improve the model/taxonomy or revive seam-aware rectification;
|
||||
2. add an instance/detection and tracking branch, then project accepted 2D
|
||||
observations through the factory camera/LiDAR calibration into the point
|
||||
cloud for distance and 3D-cuboid experiments.
|
||||
|
||||
Live processing should be admitted only after the chosen branches are profiled
|
||||
with bounded queues and an explicit drop/sampling policy. At the present 3.10
|
||||
inference FPS, this exact configuration cannot consume the recorded ~10 FPS
|
||||
camera stream without backlog.
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
# LAB E5 — Instance detection and temporal tracking qualification
|
||||
|
||||
Date: 2026-07-21
|
||||
Completed at: 2026-07-21T08:45:46.307Z
|
||||
State: completed qualification baseline, model output, not ground truth
|
||||
Ops card: MISSIONCOR-19
|
||||
|
||||
## Objective
|
||||
|
||||
Establish the first measured temporal object-identity baseline on recorded K1
|
||||
camera data before attempting LiDAR association and 3D cuboids. The acceptance
|
||||
target was deliberately bounded:
|
||||
|
||||
1. run an upstream, reproducibly pinned detector through the existing Triton
|
||||
service on the AI worker;
|
||||
2. reuse the immutable calibration-bound valid-FOV mask instead of processing
|
||||
the black fisheye border;
|
||||
3. attach persistent IDs with a ByteTrack-style two-stage association loop;
|
||||
4. produce a watchable 2D tracking video and exact timestamped frame metadata;
|
||||
5. measure detector, tracking, artifact, GPU, memory and disk behavior;
|
||||
6. retain all task-controlled worker data under `D:\NDC_MISSIONCORE`, enforce
|
||||
the 360 GiB free-space floor and restore the prior Triton model state;
|
||||
7. record visible failure modes honestly before using these identities for 3D.
|
||||
|
||||
LAB E5 is a qualification clip, not a full-session or saved-session production
|
||||
source. It does not claim tracking ground truth, ID-switch accuracy, instance
|
||||
masks, 3D cuboids, LiDAR distance, point-cloud labels, real-time operation or
|
||||
safety fitness.
|
||||
|
||||
## Immutable input and clip selection
|
||||
|
||||
- UI session alias: `RAVNOVES00`
|
||||
- Session ID: `20260720T065719Z_viewer_live`
|
||||
- Compute job: `recorded-camera-602ac89026ed12978619801d`
|
||||
- Source: `sensor.camera.right`
|
||||
- Factory calibration slot: `camera_1`
|
||||
- Input SHA-256:
|
||||
`602ac89026ed12978619801d4edea0cae24b5cc3afabd9f7af2858de6505a20e`
|
||||
- Factory calibration content SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- Codec epoch: 1
|
||||
- Resolution: 800x600
|
||||
- Full source: 4,489 frames, 35.421857292–484.144857292 session seconds
|
||||
- Qualification selection: source frames 1,000–1,600 inclusive
|
||||
- Frames admitted: 601/601
|
||||
- Qualification timeline: 135.365857292–195.334857292 session seconds
|
||||
- Qualification media duration: 60.068948 seconds
|
||||
- Qualification timeline SHA-256:
|
||||
`06e3514228b488137b92c303cbca171154ede00368783e32107ed2c39c41baa6`
|
||||
|
||||
Frames 1,000–1,600 were selected before the full run. The contiguous interval
|
||||
contains stationary and changing views, multiple parked vehicles, distant
|
||||
pedestrians, and a woman with a child/stroller passing close to the camera.
|
||||
That close pass deliberately stresses scale change, fisheye distortion,
|
||||
occlusion and identity continuity. It was not selected from the final output to
|
||||
make the result look favorable.
|
||||
|
||||
The valid image circle is the immutable LAB E1 artifact
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`.
|
||||
Its mask SHA-256 is
|
||||
`a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`.
|
||||
It admits 270,606 of 480,000 pixels. The mask is loaded once and reused; it is
|
||||
not recomputed per frame.
|
||||
|
||||
## Accepted software configuration
|
||||
|
||||
Detector:
|
||||
|
||||
- official Megvii YOLOX-S ONNX release `0.1.1rc0`;
|
||||
- license: Apache-2.0;
|
||||
- classes: COCO-80, filtered to person, bicycle, car, motorcycle, bus and
|
||||
truck;
|
||||
- model SHA-256:
|
||||
`c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
|
||||
- Triton config SHA-256:
|
||||
`5795c737a7935a655961b069e8404d336d891f9762fb6dffb93956a076479604`;
|
||||
- input: FP32 `[1,3,640,640]`, BGR, bilinear top-left letterbox, fill 114;
|
||||
- output: FP32 `[1,8400,85]`, official grid/stride decode;
|
||||
- container: `nvcr.io/nvidia/tritonserver:26.06-py3`;
|
||||
- Triton backend: ONNX Runtime GPU.
|
||||
|
||||
Detection and FOV filtering:
|
||||
|
||||
- detector minimum score: 0.10;
|
||||
- high/new-track threshold: 0.25;
|
||||
- same-class NMS IoU: 0.45;
|
||||
- same-class containment suppression: 0.80;
|
||||
- minimum box area: 64 pixels;
|
||||
- maximum box area: 50% of image;
|
||||
- minimum valid-FOV box fraction: 0.50;
|
||||
- box center must lie inside the valid FOV.
|
||||
|
||||
Tracking:
|
||||
|
||||
- algorithm: `bytetrack-style-two-stage-iou/v1`;
|
||||
- assignment: SciPy linear-sum assignment;
|
||||
- primary high-score match IoU: 0.20;
|
||||
- secondary low-score match IoU: 0.10;
|
||||
- lost-track buffer: 15 frames;
|
||||
- confirmation: 2 hits;
|
||||
- association is class-consistent;
|
||||
- no appearance ReID and no camera-motion compensation.
|
||||
|
||||
Producer identities:
|
||||
|
||||
- pipeline: `recorded-yolox-bytetrack-style-qualification/v1`;
|
||||
- runner SHA-256:
|
||||
`adc7c924cb8d01278ccf63c9eeb3c9f810c09d2d1d7fe169e136102ee32c247a`;
|
||||
- orchestrator SHA-256:
|
||||
`8fff9c87e6213883ebde257a4226765d96b16cd4b8a2a5bb492aef728e3e2242`;
|
||||
- profile SHA-256:
|
||||
`1de8a83ef0fe630ca317b70bbe3a8ec497ae7cb5267fabda2ea234ee2cf2e562`;
|
||||
- Python 3.12.3, NumPy 2.5.1 and SciPy 1.16.3 inside the bounded run
|
||||
container.
|
||||
|
||||
The profile was frozen after the second visual pilot. No thresholds were tuned
|
||||
against the 601-frame result.
|
||||
|
||||
## Execution topology
|
||||
|
||||
The pipeline was sequential at frame level:
|
||||
|
||||
```text
|
||||
recorded fMP4 epoch
|
||||
-> exact RGB frame reconstruction
|
||||
-> fixed calibration-bound valid-FOV fill
|
||||
-> YOLOX-S Triton request
|
||||
-> YOLOX decode and class/FOV filtering
|
||||
-> same-class NMS plus nested-box suppression
|
||||
-> high-score association
|
||||
-> low-score association to unmatched tracks
|
||||
-> confirm/age/retire tracks
|
||||
-> write ID boxes and trails
|
||||
-> append exact timestamped detections/tracks
|
||||
-> encode overlays as H.264
|
||||
-> hash, validate and publish qualification result
|
||||
```
|
||||
|
||||
Batch size was one. Frames were not parallelized. Triton/CUDA may execute
|
||||
internal work asynchronously, but each source frame completed decode,
|
||||
inference, association and overlay writing before the next frame was admitted.
|
||||
NVENC encoded the completed overlay sequence after inference.
|
||||
|
||||
The model was absent from Triton's ready set before the run. The orchestrator
|
||||
loaded it only for LAB E5 and restored the original unloaded state in `finally`.
|
||||
The pre-existing `mission-core-triton`, `sentinel-frigate` and
|
||||
`sentinel-ollama` services remained running.
|
||||
|
||||
## Pilot history and configuration freeze
|
||||
|
||||
The first launch failed closed during preflight because the base Triton image
|
||||
did not contain Pillow. It performed no inference, loaded no model and
|
||||
published no result. The correction mounted the already existing D-only
|
||||
perception environment read-only at `/opt/env`; no new environment was placed
|
||||
on C:.
|
||||
|
||||
The first successful 32-frame pilot used frames 1,230–1,261 and published the
|
||||
immutable diagnostic result
|
||||
`e5-tracking-005090ae34dc2c09d75f0dcfe68b4f9eed9e011d480e0c4b0d650be9369ed841`.
|
||||
It exposed two implementation effects:
|
||||
|
||||
1. nested same-class body/torso boxes survived conventional IoU NMS;
|
||||
2. the first SciPy assignment import contaminated the first tracking latency
|
||||
with a 422 ms one-time cost.
|
||||
|
||||
The accepted runner added 80% containment suppression and warmed the assignment
|
||||
solver before timed frames. A second 32-frame pilot then published
|
||||
`e5-tracking-8035c0013e36a7cfb68aa3652b05ab79fe36baf136462d5fbf91b348ee85ec08`.
|
||||
It processed 32/32 frames in 4.843450 seconds at 6.606861 FPS, reduced
|
||||
detections from 235 to 227, retained zero same-class pairs at IoU >= 0.8 and
|
||||
reduced tracking p95 to 0.401 ms. Visual inspection confirmed that the remaining
|
||||
nearby person boxes represented the woman and child, not nested duplicates.
|
||||
|
||||
Both successful pilots remain immutable evidence. Only the second profile was
|
||||
admitted to the main qualification run.
|
||||
|
||||
## Qualification result
|
||||
|
||||
Published result:
|
||||
`e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6`.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Frames expected / processed | 601 / 601 |
|
||||
| Frames failed / skipped | 0 / 0 |
|
||||
| Frames with confirmed tracks | 600 |
|
||||
| Inference/tracking/artifact loop | 88.092835 s |
|
||||
| Loop throughput | 6.822348 FPS |
|
||||
| Stream extraction | 10.243353 s |
|
||||
| H.264 encoding | 0.686277 s |
|
||||
| Full worker wall | 166.335664 s |
|
||||
| End-to-end throughput | 3.613176 FPS |
|
||||
| Process peak RSS | 126.199 MiB |
|
||||
|
||||
The source rate is approximately 10.004 FPS. The measured frame loop is about
|
||||
1.47 times slower than source real time, and the complete worker run is about
|
||||
2.77 times slower. This exact artifact-heavy path is therefore offline. It is
|
||||
not admitted as a live pipeline.
|
||||
|
||||
### Per-frame latency
|
||||
|
||||
| Stage | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| End to end | 146.279 ms | 144.622 ms | 163.340 ms | 215.759 ms |
|
||||
| Overlay write | 87.363 ms | 86.227 ms | 99.219 ms | 139.534 ms |
|
||||
| Image decode | 30.132 ms | 28.455 ms | 40.723 ms | 63.155 ms |
|
||||
| Triton request | 12.359 ms | 12.148 ms | 17.948 ms | 31.662 ms |
|
||||
| Detector postprocess | 7.307 ms | 7.016 ms | 10.734 ms | 21.003 ms |
|
||||
| Preprocess | 6.202 ms | 5.803 ms | 7.991 ms | 18.645 ms |
|
||||
| Tracking | 0.247 ms | 0.234 ms | 0.390 ms | 0.894 ms |
|
||||
|
||||
The tracker is not the speed problem. The Triton request itself is also below
|
||||
the approximately 100 ms/frame source budget on average. Synchronous PNG
|
||||
overlay writing and image decoding dominate this qualification loop. A live
|
||||
design must decouple visualization/artifact production from the inference
|
||||
critical path and define bounded queues and a drop policy; it must not simply
|
||||
run this script against an unbounded stream.
|
||||
|
||||
## Detection and identity observations
|
||||
|
||||
| Metric | Result |
|
||||
|---|---:|
|
||||
| Detections admitted | 4,049 |
|
||||
| Confirmed track observations | 3,320 |
|
||||
| Confirmed track IDs | 167 |
|
||||
| Tracker tracks created / retired | 177 / 166 |
|
||||
| Confirmed length mean / p50 / p95 / max | 19.88 / 6 / 94 / 212 frames |
|
||||
| Same-class duplicate pairs at IoU >= 0.8 | 0 |
|
||||
| Rejected oversized boxes | 7 |
|
||||
|
||||
Admitted detections by detector label:
|
||||
|
||||
| Label | Detections | Confirmed observations | Confirmed IDs |
|
||||
|---|---:|---:|---:|
|
||||
| car | 2,972 | 2,556 | 123 |
|
||||
| truck | 646 | 483 | 15 |
|
||||
| person | 364 | 249 | 24 |
|
||||
| bicycle | 45 | 29 | 3 |
|
||||
| motorcycle | 16 | 2 | 1 |
|
||||
| bus | 6 | 1 | 1 |
|
||||
|
||||
Track persistence without ground-truth interpretation:
|
||||
|
||||
- 150/167 IDs had at least 2 confirmed observations;
|
||||
- 97/167 had at least 5 observations;
|
||||
- 63/167 had at least 10 observations;
|
||||
- 30/167 had at least 30 observations;
|
||||
- 15/167 had at least 60 observations;
|
||||
- 7/167 had at least 100 observations.
|
||||
|
||||
These counts prove that the temporal state machine is operating; they do not
|
||||
measure accuracy. The high number of short vehicle IDs is a visible baseline
|
||||
limitation, driven by changing viewpoint, intermittent low-confidence
|
||||
detections and class changes between `car` and `truck` while association is
|
||||
class-consistent.
|
||||
|
||||
### Visual findings
|
||||
|
||||
1. Boxes remain inside the calibrated valid image circle; the black lens border
|
||||
no longer produces visual clutter or tracking candidates.
|
||||
2. Parked vehicles and normally moving people retain IDs over useful clear-view
|
||||
intervals. Several vehicle tracks persist for 100–212 frames.
|
||||
3. The close woman/child sequence demonstrates both success and failure. IDs
|
||||
54 and 60 persist through the unobstructed approach. When the woman/stroller
|
||||
fills the lower-left fisheye region and becomes heavily occluded, ID 60 is
|
||||
lost and a new person ID 76 begins. This is a real identity fragmentation,
|
||||
not hidden by the report.
|
||||
4. YOLOX-S sometimes classifies the stroller as `motorcycle`, and the same
|
||||
parked vehicle can alternate between `car` and `truck`. COCO has no stroller
|
||||
class, and the generic road detector is not calibrated for the K1 fisheye.
|
||||
5. Conventional NMS plus containment suppression removed the visible nested
|
||||
body/torso duplicates from the first pilot without suppressing the distinct
|
||||
woman and child.
|
||||
6. There is no human-reviewed identity ground truth. IDF1, HOTA, MOTA and true
|
||||
ID-switch count therefore remain unknown and must not be inferred from the
|
||||
track-length statistics.
|
||||
|
||||
## GPU and neighboring services
|
||||
|
||||
Worker GPU: NVIDIA GeForce RTX 4090.
|
||||
|
||||
- one-second GPU samples: 88;
|
||||
- GPU utilization: 51.65% mean, 52% p50, 55% p95, 56% max;
|
||||
- board memory used, including pre-existing services: 10,239.0 MiB mean,
|
||||
10,249 MiB max;
|
||||
- memory-controller utilization: 6.11% mean, 7% max;
|
||||
- power: 144.78 W mean, 146.71 W p95, 148.40 W max;
|
||||
- temperature: 42.60 C mean, 43 C max;
|
||||
- process peak RSS: 126.199 MiB.
|
||||
|
||||
The board-memory value is total device use observed by `nvidia-smi`, not an E5
|
||||
process allocation. This run did not expose a trustworthy per-process CUDA
|
||||
peak, so none is claimed.
|
||||
|
||||
## Disk policy and measured usage
|
||||
|
||||
The orchestrator admitted only explicit worker paths rooted at
|
||||
`D:\NDC_MISSIONCORE`. It reserved a conservative 4,726,173,047-byte working set,
|
||||
checked D: at each major phase and every 100 frames, and failed closed if the
|
||||
360 GiB floor would be crossed.
|
||||
|
||||
| Disk point | Free bytes | Free GiB |
|
||||
|---|---:|---:|
|
||||
| Before run | 409,176,440,832 | 381.075 |
|
||||
| After frame extraction | 408,682,668,032 | 380.615 |
|
||||
| After inference | 408,303,550,464 | 380.262 |
|
||||
| After final artifacts | 408,300,429,312 | 380.259 |
|
||||
| After exact temporary cleanup | 408,660,688,896 | 380.595 |
|
||||
| Enforced floor | 386,547,056,640 | 360.000 |
|
||||
|
||||
The floor was never approached closer than 20.259 GiB. Only exact E5 temporary
|
||||
stream, frame and overlay roots were removed. The immutable pilots, accepted
|
||||
result, telemetry, runner and profiles remain on D:. No task-controlled C: path
|
||||
was created, mounted or used. This does not claim visibility into incidental
|
||||
internal writes by Windows or the pre-existing Docker Desktop engine.
|
||||
|
||||
## Artifacts and independent validation
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `tracking.mp4` | 12,270,533 | `d911efc46c5c3df38ca961700f82c407c44222ba465af752d2a7de75911740ba` |
|
||||
| `frames.jsonl` | 1,116,700 | `e5f19e5dd8bf76040cb7859ffada3f1bb68a0f5cd93fac243947276899cce014` |
|
||||
| `gpu-telemetry.jsonl` | 19,694 | `84cbaa4d5a7d503129f2fa329ab2b231cff54a6972cd31ba95fe91f48f01adad` |
|
||||
| `contact-sheet.png` | 926,575 | `d9a23cc76e1a9a4aba1156609f85b781bc2502cd80f3b17ac83ca4228f2121a8` |
|
||||
| `run-report.json` | 9,183 | `96d1cb059b35a0aa3fcc7c3458a0f47f57a4bd52a64c34f3c7607302e49206d9` |
|
||||
|
||||
Independent local validation confirmed:
|
||||
|
||||
- the result directory name equals its canonical identity SHA-256;
|
||||
- all five artifact size/digest pairs match `result.json`;
|
||||
- the result is bound to the exact job, input, session, source, clip bounds and
|
||||
timestamp basis;
|
||||
- `frames.jsonl` contains exactly 601 ordered source indices and strictly
|
||||
increasing session timestamps;
|
||||
- every reported detection and track box is finite and inside 800x600;
|
||||
- no frame failed or was skipped;
|
||||
- FFprobe reports H.264, yuv420p, 800x600, 60.068948 seconds, and exactly
|
||||
601 declared/read frames;
|
||||
- the custom result validator accepts the immutable result;
|
||||
- the E5 source and test files pass Ruff, and the focused E5 test suite covers
|
||||
profile pinning, FOV math, YOLOX decode, containment suppression, two-stage
|
||||
low-score identity retention, class isolation and nonzero source timelines.
|
||||
|
||||
Durable local result root:
|
||||
|
||||
```text
|
||||
.runtime/compute-experiments/e5/results/
|
||||
e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6/
|
||||
```
|
||||
|
||||
Worker evidence root:
|
||||
|
||||
```text
|
||||
D:\NDC_MISSIONCORE\runtime\derived\
|
||||
e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6
|
||||
```
|
||||
|
||||
## Acceptance status
|
||||
|
||||
Accepted:
|
||||
|
||||
- reproducible official YOLOX-S inference through the existing Triton worker;
|
||||
- fixed calibration-bound valid-FOV preprocessing;
|
||||
- ByteTrack-style high/low-score temporal association;
|
||||
- 601/601 exact recorded frames with no failures or skips;
|
||||
- watchable 2D ID overlay, trails and complete frame metadata;
|
||||
- honest timing, GPU, process and disk telemetry;
|
||||
- content-addressed immutable qualification artifacts;
|
||||
- D-only task-controlled worker storage and restoration of Triton model state.
|
||||
|
||||
Not accepted:
|
||||
|
||||
- production-quality object classes or identity continuity;
|
||||
- IDF1, HOTA, MOTA or measured ID-switch rate;
|
||||
- handling of heavy close occlusion, re-entry or long disappearance;
|
||||
- appearance ReID or camera-motion compensation;
|
||||
- instance masks, 3D cuboids, LiDAR association or metric distance;
|
||||
- a live unbounded stream or safety use;
|
||||
- promotion of the partial clip into the saved-session production source list.
|
||||
|
||||
## Decision and next gate
|
||||
|
||||
LAB E5 closes the first temporal identity baseline. The result is useful enough
|
||||
to become input to a bounded 3D fusion experiment, but not strong enough to be
|
||||
called production tracking.
|
||||
|
||||
The next gate should be LAB E6 on the same frozen 601-frame interval:
|
||||
|
||||
1. use factory `camera_1` calibration to project synchronized LiDAR points into
|
||||
each accepted 2D observation;
|
||||
2. reject points outside the valid FOV and use robust depth/cluster gates rather
|
||||
than a single nearest pixel;
|
||||
3. compute per-object range and coarse point-supported 3D cuboids with explicit
|
||||
support counts and confidence;
|
||||
4. smooth distance over the existing track ID where continuity is available;
|
||||
5. treat COCO `car`/`truck` as one vehicle association group in the fusion
|
||||
layer so detector class flicker does not automatically break geometry;
|
||||
6. publish a bounded Rerun qualification recording only after visual and
|
||||
numeric checks pass.
|
||||
|
||||
In parallel with, but not as a prerequisite for, this first 3D proof, the 2D
|
||||
branch should later compare camera-motion compensation and appearance ReID on a
|
||||
small human-reviewed identity set. Full-session and live runs should wait until
|
||||
the artifact-writing path is removed from the inference critical section and a
|
||||
bounded queue/drop policy has been tested.
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
# LAB E6 — Factory-calibrated tracked LiDAR fusion
|
||||
|
||||
Date: 2026-07-21
|
||||
Completed at: 2026-07-21T09:18:15.532Z
|
||||
State: completed qualification baseline, diagnostic geometry, not ground truth
|
||||
Ops card: pending direct `nodedc-ops-agent` availability
|
||||
|
||||
## Objective
|
||||
|
||||
Connect the already qualified E5 temporal identities to metric observations from
|
||||
the K1 LiDAR without inventing depth from image boxes. The bounded acceptance
|
||||
target was:
|
||||
|
||||
1. reuse the exact 601-frame E5 interval and E4 semantic masks;
|
||||
2. bind the right camera to factory calibration slot `camera_1`;
|
||||
3. project raw K1 points with the factory KB4 intrinsics and camera/LiDAR
|
||||
extrinsics;
|
||||
4. reject camera/point/pose combinations outside a strict 100 ms host-arrival
|
||||
gate;
|
||||
5. associate only semantically compatible, nearest-depth, spatially connected
|
||||
LiDAR support with each tracked image object;
|
||||
6. estimate a robust metric range and a point-supported oriented 3D envelope;
|
||||
7. publish a watchable overlay and a standalone Rerun recording containing the
|
||||
camera view, map-frame point cloud, support points and translucent `Boxes3D`;
|
||||
8. measure the geometry path and preserve every pilot and accepted artifact.
|
||||
|
||||
LAB E6 proves that the existing XGRIDS factory calibration can drive useful
|
||||
recorded camera/LiDAR fusion. It does not claim amodal vehicle dimensions,
|
||||
object-complete 3D boxes, ground truth, live-rate admission or safety fitness.
|
||||
|
||||
## Immutable inputs
|
||||
|
||||
- UI session alias: `RAVNOVES00`
|
||||
- Session ID: `20260720T065719Z_viewer_live`
|
||||
- Camera job: `recorded-camera-602ac89026ed12978619801d`
|
||||
- Camera source: `sensor.camera.right`
|
||||
- Factory calibration slot: `camera_1`
|
||||
- Camera input SHA-256:
|
||||
`602ac89026ed12978619801d4edea0cae24b5cc3afabd9f7af2858de6505a20e`
|
||||
- Factory calibration content SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- Raw MQTT capture SHA-256:
|
||||
`70da0edad5cbf0e89b6e2355c0aac294f8c36a67249cd33b8918353fd88c83af`
|
||||
- E4 semantic result:
|
||||
`result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30`
|
||||
- E5 tracking result:
|
||||
`e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6`
|
||||
- Source frames: 1,000–1,600 inclusive
|
||||
- Frames admitted: 601/601
|
||||
- Session interval: 135.365857292–195.334857292 seconds
|
||||
- Media duration: 60.068948 seconds
|
||||
- Resolution: 800x600
|
||||
|
||||
The 127 MiB MQTT capture contains the raw point and pose observations used by
|
||||
the run. The calibration snapshot files are preserved separately from Git:
|
||||
|
||||
| File | SHA-256 |
|
||||
|---|---|
|
||||
| `camera.yaml` | `d123ebd2207e18a800cf5d07d055bf38231d28390bf3ba8c4e718e3cb3a7775e` |
|
||||
| `extrinsic_camera_lidar.yaml` | `d09b2eda51e1deb7e3a31ff8441cba6006e8da60afc3e33de44a857437dfcfb8` |
|
||||
| `manifest.json` | `75e1d582f661dabafb48fc6e8c7aabe0f217e0d95ed934b7d9dac18bdfc1216c` |
|
||||
|
||||
## Accepted configuration
|
||||
|
||||
Producer identities:
|
||||
|
||||
- pipeline: `factory-kb4-tracked-box-semantic-lidar/v1`;
|
||||
- profile schema: `missioncore.e6-tracking-lidar-profile/v1`;
|
||||
- profile SHA-256:
|
||||
`b8d3a6f043fde54ffd32ea05bfe580e2be7aace1a23cdbf7e266eb3325d147b2`;
|
||||
- runner SHA-256:
|
||||
`9765be5e0338b9978db7a58a739313b228bc7ceee1352a7a76af0ff2a528a953`.
|
||||
|
||||
Temporal binding:
|
||||
|
||||
- nearest host-arrival observation, best effort;
|
||||
- maximum LiDAR/camera delta: 100 ms;
|
||||
- maximum pose/point delta: 100 ms;
|
||||
- frames outside the gate fail closed for depth; image tracking remains visible.
|
||||
|
||||
Projection and occlusion:
|
||||
|
||||
- factory KB4 projection for `camera_1`;
|
||||
- source points transformed from the K1 map frame through the recorded pose;
|
||||
- only camera-front, in-frame points are retained;
|
||||
- one nearest-depth point is retained per rounded image pixel;
|
||||
- no fabricated depth and no 2D-box extrusion.
|
||||
|
||||
Association:
|
||||
|
||||
- image boxes are inset 5% horizontally, 4% at the top and 2% at the bottom;
|
||||
- `car`, `truck` and `bus` share the `vehicle` association group;
|
||||
- same-frame vehicle overlap suppression uses IoU 0.55;
|
||||
- E4 semantic IDs gate LiDAR support: person 1, bicycle 2, motorcycle 3,
|
||||
vehicle 4/5;
|
||||
- depth splitting uses `max(0.5 m, 6% of range)`;
|
||||
- the selected depth component is split again by 3D connected components;
|
||||
- spatial radii are 0.9 m for person/bicycle/motorcycle and 1.5 m for vehicle;
|
||||
- minimum support is 3 points for person/bicycle/motorcycle and 5 for vehicle;
|
||||
- distance uses cluster median, with p10 retained for diagnostics;
|
||||
- a five-frame track history smooths distance;
|
||||
- an observation is rejected when its distance innovation exceeds
|
||||
`max(1.5 m, 25% of track history)`.
|
||||
|
||||
Cuboid construction:
|
||||
|
||||
- yaw comes from PCA over supported map-frame XY points;
|
||||
- dimensions are the oriented p05–p95 support extent;
|
||||
- every axis has a 0.15 m diagnostic minimum for visibility;
|
||||
- maximum accepted extents are 1.5x1.5x2.8 m for person,
|
||||
3.5x2.0x2.5 m for bicycle/motorcycle and 6.5x3.5x4.0 m for vehicle;
|
||||
- rejected or unsupported detections never produce a 3D cuboid.
|
||||
|
||||
## Execution topology and resource boundary
|
||||
|
||||
The E6 geometry stage ran as a single local CPU process on the Mission Core
|
||||
host, an Apple M3 Pro with 18 GiB RAM. It used Python 3.12.13, NumPy 2.5.1,
|
||||
Pillow 12.3.0, Rerun 0.34.1 and FFmpeg 7.1.1. The external AI worker and its
|
||||
Triton model state were not changed. No path on worker disk C: or D: was read,
|
||||
written or cleaned by this stage.
|
||||
|
||||
```text
|
||||
exact E5 frame + track IDs
|
||||
+ exact E4 semantic mask
|
||||
+ nearest gated K1 point/pose sample
|
||||
-> factory KB4 camera projection
|
||||
-> per-pixel nearest-depth buffer
|
||||
-> box inset + semantic support gate
|
||||
-> 1D depth split + 3D connected component
|
||||
-> range innovation gate
|
||||
-> robust range + PCA-yaw p05/p95 envelope
|
||||
-> overlay / NPZ / JSONL / Rerun recording
|
||||
-> content hash, qualification validation and publish
|
||||
```
|
||||
|
||||
Frames were processed sequentially. Artifact writing was performed inside the
|
||||
same measured process. This is a recorded qualification path, not the live
|
||||
queue/worker architecture.
|
||||
|
||||
## Pilot history and profile freeze
|
||||
|
||||
Three immutable 32-frame pilots were retained:
|
||||
|
||||
1. `e6-fusion-a72468...` exposed 7–8 m person envelopes when a depth-only
|
||||
cluster admitted background structure. It was rejected.
|
||||
2. `e6-fusion-693246...` added 3D connectivity and plausible size gates. Box
|
||||
dimensions became plausible, but range could still jump between foreground
|
||||
and background support on the same track. It was rejected.
|
||||
3. `e6-fusion-70fa670ed9005957266ae7a46ac2bfd2d526f1ea170cd052f4494a2397fff76f`
|
||||
added the distance-innovation gate and was accepted. It fused 32/32 frames,
|
||||
produced 47 cuboids across five track IDs, rejected two range jumps, ran in
|
||||
3.681 seconds and peaked at 159.828 MiB RSS.
|
||||
|
||||
The accepted configuration was frozen after the third pilot. The 601-frame
|
||||
result was not used to tune its thresholds.
|
||||
|
||||
## Qualification result
|
||||
|
||||
Published result:
|
||||
`e6-fusion-b4e4226674a66f6196c033785eb307c255a7bdd8493ef9809dfb1d6e5bd68eaa`.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Frames expected / processed | 601 / 601 |
|
||||
| Frames fused | 526 |
|
||||
| Frames outside strict sync gate | 75 |
|
||||
| Frames with at least one accepted cuboid | 438 |
|
||||
| Accepted cuboids | 918 |
|
||||
| Unique track IDs with depth | 56 |
|
||||
| Source cloud points consumed | 1,182,292 |
|
||||
| Accepted support points | 14,025 |
|
||||
| Geometry/artifact wall time | 14.774254 s |
|
||||
| Effective throughput | 40.678 FPS |
|
||||
| User / system CPU | 13.571966 / 0.772126 s |
|
||||
| Process peak RSS | 240.469 MiB |
|
||||
|
||||
This recorded geometry/artifact stage is about 4.07 times faster than the
|
||||
approximately 10.004 FPS camera source. That does not establish a live
|
||||
end-to-end rate: E4 segmentation and E5 detection/tracking are precomputed
|
||||
inputs and were not included in the 14.774-second measurement.
|
||||
|
||||
### Synchronization
|
||||
|
||||
| Delta | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| LiDAR to camera | 29.389 ms | 23.905 ms | 70.794 ms | 96.107 ms |
|
||||
| Pose to point cloud | 7.940 ms | 6.370 ms | 19.113 ms | 46.139 ms |
|
||||
|
||||
All fused frames satisfy the configured 100 ms gates. The 75 nonconforming
|
||||
camera frames retain their 2D presentation but carry no depth or 3D claim.
|
||||
Host-arrival time is still only a best-effort clock; hardware timestamps have
|
||||
not been ground-truthed.
|
||||
|
||||
### Accepted geometry distribution
|
||||
|
||||
| Group | Cuboids | Unique track IDs |
|
||||
|---|---:|---:|
|
||||
| Vehicle | 809 | 43 |
|
||||
| Person | 96 | 12 |
|
||||
| Bicycle | 13 | 1 |
|
||||
| Total | 918 | 56 |
|
||||
|
||||
- accepted range: 1.239–37.749 m, p50 10.189 m, p95 24.126 m;
|
||||
- support per cuboid: minimum 3, p50 10, p95 45, maximum 89 points;
|
||||
- p95 oriented extent: 5.383x1.197x2.222 m;
|
||||
- maximum admitted extent: 6.490x2.948x2.783 m.
|
||||
|
||||
The extent is an envelope of the visible supported surface. It is intentionally
|
||||
not an amodal estimate of the object's complete physical volume. A parked car
|
||||
seen from one side can therefore receive a thin cuboid that does not wrap the
|
||||
whole vehicle silhouette.
|
||||
|
||||
### Fail-closed decisions
|
||||
|
||||
| Status | Observations |
|
||||
|---|---:|
|
||||
| Accepted point-supported cuboid | 918 |
|
||||
| Fewer than 5 vehicle support points | 1,023 |
|
||||
| No semantically compatible LiDAR support | 365 |
|
||||
| Rejected distance innovation | 209 |
|
||||
| Fewer than 3 non-vehicle support points | 28 |
|
||||
| Implausible cuboid extent | 24 |
|
||||
|
||||
These rejection counts are part of the result, not discarded errors. In
|
||||
particular, the high low-support count prevents distant image detections from
|
||||
being turned into false metric objects.
|
||||
|
||||
## Rerun and visual QA
|
||||
|
||||
The Rerun recording logs seven entities across the `session_time` timeline:
|
||||
|
||||
- encoded camera fusion overlay;
|
||||
- raw map-frame LiDAR points;
|
||||
- selected object support points;
|
||||
- translucent oriented `Boxes3D` with track/class/range labels;
|
||||
- a visible qualification contract stating the timing and safety limits.
|
||||
|
||||
The web viewer was loaded from the local gRPC proxy and inspected at multiple
|
||||
timestamps. Camera overlay, point cloud, support and `Boxes3D` layers load
|
||||
together. Selecting a cuboid exposes its real center, half-sizes, quaternion,
|
||||
fill mode and label in Rerun. Browser logs contained no load/render errors;
|
||||
only Rerun web-backend deprecation/default warnings were present.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `fusion.npz` | 9,745,489 | `3e0e63797c56505eb9755d253f262459d46ec841e80cc5b785a6b495ed2007f9` |
|
||||
| `fusion-frames.jsonl` | 1,607,124 | `ea1621bb337c6c268f91f6e2c7cef997d78672da4025975328c9e2583a0718e0` |
|
||||
| `box-labels.json` | 30,328 | `0c423024a086344fcdbac8665635a45e88fef81270be73d9e1af3e6bc4eb860c` |
|
||||
| `fusion-overlay.mp4` | 24,156,459 | `a863d1910eb973cef1f4cca28432cb16b26030149e197ff7f9ad3d168b69954c` |
|
||||
| `fusion.rrd` | 63,092,299 | `77b0b91171176dd7ea6d2ef726cc946ab1fc24367750101d3f10617360cb4d63` |
|
||||
| `contact-sheet.png` | 3,066,509 | `adcad4db4fd3089175ed947b312129fc86f04abc065314175eceb62368adac49` |
|
||||
| `run-report.json` | 5,405 | `ca01cb5fa0325d80504199b623f2972141f45b680b4b16ad6a5271f9dd1deb2c` |
|
||||
|
||||
FFprobe independently confirmed H.264/yuv420p, 800x600, 601 declared/read
|
||||
frames and 60.068948 seconds. The custom E6 validator rehashed every artifact
|
||||
and verified the exact session, job, camera, calibration, clip, upstream result
|
||||
and configuration identities. The complete E6 workspace including all pilots
|
||||
uses 124 MiB on the Mission Core host.
|
||||
|
||||
## What is proven and what remains open
|
||||
|
||||
Proven:
|
||||
|
||||
- the right-camera/`camera_1` factory calibration is operational for recorded
|
||||
K1 camera/LiDAR projection;
|
||||
- E5 track identities can receive robust metric LiDAR ranges;
|
||||
- unsupported 2D boxes do not become fake 3D geometry;
|
||||
- Rerun can display the camera, map cloud, support and true oriented 3D
|
||||
primitives on one timeline;
|
||||
- the geometry stage itself is comfortably faster than source rate on this
|
||||
host.
|
||||
|
||||
Not proven:
|
||||
|
||||
- object-complete vehicle/person volume or metric box accuracy;
|
||||
- calibration error in pixels or centimetres against a measured target;
|
||||
- hardware-clock synchronization accuracy;
|
||||
- tracking accuracy, instance-mask accuracy or semantic ground truth;
|
||||
- live end-to-end throughput, bounded queues, drop policy or recovery;
|
||||
- safety or navigation fitness.
|
||||
|
||||
## Next gate — LAB E7
|
||||
|
||||
Use this exact E6 result as the control and improve 3D geometry without hiding
|
||||
rejections. The bounded E7 experiment should add ground removal, object-aware
|
||||
support completion and a reviewed upstream 3D detection baseline; compare box
|
||||
coverage, range stability, support/rejection rates and runtime on the same
|
||||
601-frame interval. Only after that comparison should the winning geometry
|
||||
path be connected to a bounded live queue. Multi-camera fusion and product UI
|
||||
work remain outside this gate.
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
# LAB E7 — Bounded replay-real-time perception loop
|
||||
|
||||
Date: 2026-07-21
|
||||
Completed at: 2026-07-21T10:12:16.828Z
|
||||
State: accepted replay scheduler and world-state qualification
|
||||
Navigation/safety state: not accepted
|
||||
Ops card: pending direct `nodedc-ops-agent` availability
|
||||
|
||||
## Objective
|
||||
|
||||
Stop treating recorded perception as a video-enhancement exercise and establish
|
||||
the first measured runtime contract for the K1 as the eyes of an autonomous
|
||||
platform. LAB E7 replays the immutable E6 interval at its original timing and
|
||||
requires the derived perception path to:
|
||||
|
||||
1. publish a machine-readable world state, not only a rendered overlay;
|
||||
2. keep derived work in a small bounded queue;
|
||||
3. absorb short source bursts without discarding processable frames;
|
||||
4. discard old derived frames under real overload instead of building latency;
|
||||
5. expose result age, queue depth, drops and explicit health states;
|
||||
6. preserve object identity, metric range and 3D geometry in both map and
|
||||
LiDAR-relative coordinates;
|
||||
7. reject untrustworthy velocity estimates instead of publishing extreme
|
||||
motion caused by cuboid jitter;
|
||||
8. emit a diagnostic polar clearance observation from the LiDAR;
|
||||
9. prove the scheduler at the approximately 10 Hz source rate before connecting
|
||||
the real inference worker or live K1 stream.
|
||||
|
||||
This experiment qualifies the downstream scheduler, world-state contract and
|
||||
diagnostic publication path. E4 segmentation, E5 detection/tracking and E6
|
||||
fusion are immutable precomputed inputs. LAB E7 therefore does not claim live
|
||||
inference, live sensor transport, collision avoidance or navigation fitness.
|
||||
|
||||
## Immutable input binding
|
||||
|
||||
- Session: `20260720T065719Z_viewer_live`
|
||||
- Source: `sensor.camera.right`
|
||||
- Camera slot: `camera_1`
|
||||
- Camera job: `recorded-camera-602ac89026ed12978619801d`
|
||||
- Input SHA-256:
|
||||
`602ac89026ed12978619801d4edea0cae24b5cc3afabd9f7af2858de6505a20e`
|
||||
- Factory calibration SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- E6 result:
|
||||
`e6-fusion-b4e4226674a66f6196c033785eb307c255a7bdd8493ef9809dfb1d6e5bd68eaa`
|
||||
- E7 selection: E6 frames 0–600 inclusive
|
||||
- Source frames: 1,000–1,600 inclusive
|
||||
- Frames attempted: 601
|
||||
- Replay timing span: 59.969 seconds
|
||||
- Replay speed: 1.0x
|
||||
|
||||
The adapter spent 9.490253 seconds before the timed replay revalidating inputs,
|
||||
reading the raw point/pose capture and reconstructing the exact E6 pose binding.
|
||||
That preparation is measured separately and is not counted as live latency. A
|
||||
real source will receive frames incrementally and must not preload a multi-hour
|
||||
session into memory.
|
||||
|
||||
## Accepted configuration
|
||||
|
||||
Producer identities:
|
||||
|
||||
- pipeline: `bounded-latest-wins-e6-world-state-replay/v1`;
|
||||
- profile schema: `missioncore.e7-replay-realtime-profile/v1`;
|
||||
- profile SHA-256:
|
||||
`7222c65ddef20798a242d83edc53973cdfd6f5dec8fb9ffb6fcb5dbcef57f1c5`;
|
||||
- runner SHA-256:
|
||||
`ce36b919fe5860bbb81f04b29c58a8a333915b1ba9bd7f233f256ce77163b15d`;
|
||||
- runtime contract SHA-256:
|
||||
`4c65140550e47c08c528b10d51922d5f73b7d19a1c34ef07896c052f492309f5`;
|
||||
- result validator SHA-256:
|
||||
`719b966ff263f196a97b4d72e946b00fd10705a0158b7df6ecf452c6c9bc4e5b`;
|
||||
- E6 replay adapter SHA-256:
|
||||
`9765be5e0338b9978db7a58a739313b228bc7ceee1352a7a76af0ff2a528a953`.
|
||||
|
||||
Scheduler:
|
||||
|
||||
- one source-paced producer and one derived-state consumer;
|
||||
- replay speed 1.0x using the original per-frame session timestamps;
|
||||
- queue capacity: two derived frames;
|
||||
- overflow policy: evict the oldest queued derived frame;
|
||||
- normal consumer delay injection: zero;
|
||||
- stale threshold: 300 ms;
|
||||
- unavailable threshold: 1,000 ms;
|
||||
- raw evidence is outside this queue and is never dropped by it.
|
||||
|
||||
Acceptance thresholds:
|
||||
|
||||
- delivery rate at least 9.5 Hz;
|
||||
- p95 result age at most 300 ms;
|
||||
- drop fraction at most 1%;
|
||||
- maximum queue depth two;
|
||||
- process RSS at most 512 MiB.
|
||||
|
||||
## World-state contract
|
||||
|
||||
Each published state uses
|
||||
`missioncore.live-perception-world-state/v1` and contains:
|
||||
|
||||
- exact E6/source frame identity and session timestamp;
|
||||
- `healthy`, `degraded`, `stale` or `unavailable` delivery health;
|
||||
- the reason for every non-healthy state;
|
||||
- accepted obstacle track ID, class, detector label and confidence;
|
||||
- map-frame and K1-LiDAR-frame 3D positions;
|
||||
- map-frame orientation and visible-surface cuboid dimensions;
|
||||
- robust LiDAR range and support-point count;
|
||||
- qualified diagnostic map-frame velocity or an explicit rejection reason;
|
||||
- 72-sector LiDAR-relative obstacle-clearance observation.
|
||||
|
||||
The vehicle-body frame is explicitly
|
||||
`unavailable-no-rig-to-vehicle-transform`. K1 LiDAR coordinates must not be
|
||||
silently presented as the final autopilot body frame. A measured rigid
|
||||
sensor-to-vehicle transform is required before behavior integration.
|
||||
|
||||
### Velocity policy
|
||||
|
||||
Naive frame-to-frame cuboid differencing produced visibly false speeds because
|
||||
the supported surface moves within an object as viewpoint and point density
|
||||
change. The accepted implementation uses a one-second bounded history,
|
||||
pairwise slopes with at least 200 ms baselines, median velocity, a 400 ms
|
||||
minimum history, a 20 m/s diagnostic bound and a 0.75 m median position-residual
|
||||
gate.
|
||||
|
||||
The field is published only as `diagnostic-robust-history`. Observations with
|
||||
insufficient history, excessive residual or an implausible speed retain the
|
||||
object and range but publish no velocity.
|
||||
|
||||
### Clearance policy
|
||||
|
||||
The first clearance observation is deliberately simple and diagnostic:
|
||||
|
||||
- 72 horizontal sectors over 360 degrees;
|
||||
- 0.5–30 m range;
|
||||
- local ground estimate at the fifth LiDAR Z percentile;
|
||||
- obstacle slice 0.2–3.0 m above that estimate;
|
||||
- `front_m` is the nearest retained point within ±15 degrees;
|
||||
- unobserved sectors remain `null`, never free by assumption.
|
||||
|
||||
This is obstacle clearance evidence, not a traversable free-space polygon. It
|
||||
does not yet model vehicle footprint, slope, negative obstacles or a certified
|
||||
ground plane.
|
||||
|
||||
## Pilot history and architecture correction
|
||||
|
||||
### Pilot 1 — rejected queue consumption policy
|
||||
|
||||
Result:
|
||||
`e7-live-replay-a578764161fc007c8fc8f8e8b5ffc339e2e861668f13c1fee7dc017ac3c4b972`.
|
||||
|
||||
It processed 61/64 frames at 9.820 Hz with 10.787 ms p95 latency, but discarded
|
||||
three frames during short timestamp bursts even though the consumer was fast.
|
||||
The first implementation always selected the newest queued frame. This was too
|
||||
aggressive for a capacity-two jitter buffer and was rejected.
|
||||
|
||||
The correction preserves FIFO order while capacity remains available and
|
||||
evicts the oldest frame only when a new publication would overflow the queue.
|
||||
This retains short processable bursts while remaining latest-wins under actual
|
||||
backpressure.
|
||||
|
||||
### Pilot 2 — scheduler accepted, velocity rejected by review
|
||||
|
||||
Result:
|
||||
`e7-live-replay-810e2628c756d3e3694603c4da5429a8a333e684208cd137c6a076a7e9ae8b73`.
|
||||
|
||||
It processed 64/64 frames with zero drops at 10.303 Hz and 5.943 ms p95
|
||||
latency. Scheduler acceptance passed, but inspection of naive frame-difference
|
||||
velocity found a 1,028.5 m/s maximum caused by a cuboid-center jump. The run is
|
||||
retained as immutable evidence and rejected as a world-motion baseline.
|
||||
|
||||
### Overload negative control — accepted
|
||||
|
||||
Result:
|
||||
`e7-live-replay-1c5ce5718d8be7c4dfcbe540c12fe96339ca04a598e8f5f2fb85e84bc0748796`.
|
||||
|
||||
An intentional 250 ms consumer delay was applied to 80 source-paced frames:
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Frames attempted / processed / dropped | 80 / 33 / 47 |
|
||||
| Drop fraction | 58.75% |
|
||||
| Maximum / final queue depth | 2 / 0 |
|
||||
| Healthy / stale states | 1 / 32 |
|
||||
| End-to-end p95 | 468.525 ms |
|
||||
| Peak RSS | 373.656 MiB |
|
||||
|
||||
The negative control proves the intended failure mode: derived output quality
|
||||
degrades and old derived frames are discarded, but queue depth remains bounded
|
||||
and the runtime reports staleness. It does not block or delete raw evidence.
|
||||
|
||||
### Pilot 3 — accepted profile freeze
|
||||
|
||||
Result:
|
||||
`e7-live-replay-2fb63b4177900348fc876a1dc580a19015753742c3665c4a044e1f251b44a430`.
|
||||
|
||||
It processed 64/64 frames with zero drops at 10.303 Hz and 5.865 ms p95
|
||||
latency. Robust-history velocity replaced naive differencing. In this interval
|
||||
72 velocities were admitted with 0.439 m/s median, 2.046 m/s p95 and 4.811 m/s
|
||||
maximum; eight noisy histories were rejected by the position-residual gate.
|
||||
The configuration was then frozen for the full run.
|
||||
|
||||
### Preliminary full run — metrics valid, provenance incomplete
|
||||
|
||||
Result:
|
||||
`e7-live-replay-24222ede524e2e7824e4c7e6e9d17e87aa75bf3b8768f9e88c4b97eb410b633b`.
|
||||
|
||||
It processed 601/601 frames with zero drops and passed every runtime threshold.
|
||||
Final review found that its identity pinned the E7 runner but did not separately
|
||||
pin the imported runtime contract, validator and E6 adapter. The immutable run
|
||||
is retained as evidence, but it is not the reference result. Those dependencies
|
||||
were added to the identity and the complete 1.0x run was repeated.
|
||||
|
||||
## Full qualification result
|
||||
|
||||
Published result:
|
||||
`e7-live-replay-e339aaed75fff05ed893ae48770bd127cc0c764f3124fc4b6be94b5fd9f8389c`.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Frames attempted / processed / dropped | 601 / 601 / 0 |
|
||||
| Delivery rate | 10.021845 Hz |
|
||||
| Replay timing span | 59.969000 s |
|
||||
| Actual replay wall | 59.981432 s |
|
||||
| Queue capacity / maximum / final depth | 2 / 2 / 0 |
|
||||
| Healthy / degraded / stale / unavailable | 526 / 75 / 0 / 0 |
|
||||
| Process peak RSS | 398.047 MiB |
|
||||
|
||||
The 75 degraded states exactly match E6 frames that failed the strict
|
||||
camera/LiDAR timing gate. They remain timestamped states with no fabricated
|
||||
depth. Every fused E6 frame is healthy in this downstream replay. No frame was
|
||||
lost by the accepted runtime.
|
||||
|
||||
### Runtime latency
|
||||
|
||||
| Stage | Mean | p95 | Max |
|
||||
|---|---:|---:|---:|
|
||||
| Scheduled tick to world-state publication | 4.880 ms | 5.927 ms | 8.895 ms |
|
||||
| Producer scheduling lag | 4.124 ms | 5.019 ms | 5.644 ms |
|
||||
| Queue wait | 0.071 ms | 0.144 ms | 1.154 ms |
|
||||
| World projection and clearance | 0.372 ms | 0.538 ms | 1.647 ms |
|
||||
| Rerun publication | 0.257 ms | 0.355 ms | 1.662 ms |
|
||||
|
||||
The p95 result is 50 times below the 300 ms initial laboratory threshold. This
|
||||
headroom belongs only to the downstream replay/world-state stage; live decode,
|
||||
network transport, E4/E5 inference and fresh E6 geometry must still be added to
|
||||
the latency budget.
|
||||
|
||||
### Object state
|
||||
|
||||
| Group | Observations | Unique track IDs |
|
||||
|---|---:|---:|
|
||||
| Vehicle | 809 | 43 |
|
||||
| Person | 96 | 12 |
|
||||
| Bicycle | 13 | 1 |
|
||||
| Total | 918 | 56 |
|
||||
|
||||
- range p50 / p95 / maximum: 10.189 / 24.126 / 37.749 m;
|
||||
- 572 object observations received a diagnostic robust velocity;
|
||||
- 263 retained the object but reported insufficient velocity history;
|
||||
- 83 retained the object but rejected velocity due to position residual;
|
||||
- admitted speed p50 / p95 / maximum: 0.600 / 2.640 / 6.431 m/s.
|
||||
|
||||
These velocities are behavior-facing schema candidates, not validated motion
|
||||
ground truth. A 3D motion filter and annotated moving/static evaluation set are
|
||||
still required.
|
||||
|
||||
### Clearance observation
|
||||
|
||||
- valid front clearance on 526 fused frames;
|
||||
- front minimum / p50 / p95 / maximum:
|
||||
0.801 / 5.356 / 11.092 / 25.954 m;
|
||||
- observed-sector fraction p50 / p95: 81.94% / 87.50%;
|
||||
- all 75 frames without admitted LiDAR remain clearance-unavailable.
|
||||
|
||||
## Rerun visual QA
|
||||
|
||||
The standalone Rerun recording contains:
|
||||
|
||||
- the qualification contract;
|
||||
- map-frame LiDAR point cloud and oriented obstacle `Boxes3D`;
|
||||
- end-to-end latency chart;
|
||||
- queue-depth chart;
|
||||
- health-state chart;
|
||||
- the exact 59.969-second replay timeline.
|
||||
|
||||
The web viewer loaded all 601 ticks and exposed real cuboid centers, half-sizes,
|
||||
quaternions, labels and ranges. Latency remains approximately 0–8.9 ms, queue
|
||||
depth remains 0–1 at consumption with a measured maximum of two, and health
|
||||
transitions correspond to depth-unavailable E6 frames. No browser load/render
|
||||
errors were observed; only Rerun web-backend warnings were present.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `world-state.jsonl` | 1,730,546 | `6078d19c6414c2bf2f7ca9d95c5164b46f388496690d5fee67c662724fb7f956` |
|
||||
| `telemetry.jsonl` | 317,486 | `c25a01dc2d7a067672b8ae667e08450f2010ead3e3ccbaea7fde47ec3638119c` |
|
||||
| `world-state.rrd` | 16,234,276 | `540c7d91a6c4cf8c19276994f6bbafcb267686ddb1fdbae46d7fd2aba7d2c4b9` |
|
||||
| `run-report.json` | 4,163 | `04fd00324c57f6fa06cd6360256c14f70ab5c3fe51d5e662e427780bd228345d` |
|
||||
|
||||
The E7 validator rehashes every artifact, revalidates the complete E6 upstream
|
||||
chain, checks frame/drop/queue/health accounting, validates ordered world-state
|
||||
and telemetry rows, and verifies the RRF2 recording. All pilots, the preliminary
|
||||
full result and the reference result occupy 44 MiB on the Mission Core host.
|
||||
The AI worker and worker disks C: and D: were not accessed or modified.
|
||||
|
||||
## What is proven
|
||||
|
||||
- the accepted E6 observations can be delivered as machine world state at the
|
||||
source rate with a capacity-two derived queue and zero loss;
|
||||
- source timestamp bursts are absorbed without unnecessary drops;
|
||||
- real overload fails boundedly and explicitly rather than growing latency;
|
||||
- result freshness and sensor/fusion degradation are separate states;
|
||||
- accepted obstacles have map and K1-LiDAR positions, range, geometry and
|
||||
guarded velocity fields;
|
||||
- a first diagnostic LiDAR clearance signal is available;
|
||||
- Rerun remains a subscriber/diagnostic surface, not the perception authority.
|
||||
|
||||
## What remains open
|
||||
|
||||
- actual live camera/LiDAR ingestion through this scheduler;
|
||||
- network and inference latency on the RTX worker;
|
||||
- 10 Hz detector/tracker execution rather than precomputed E5 observations;
|
||||
- asynchronous lower-rate semantic inference rather than precomputed E4 masks;
|
||||
- current-frame E6 fusion rather than precomputed geometry;
|
||||
- calibrated K1-to-vehicle-body transform;
|
||||
- ground-truthed velocity, clearance, object and free-space accuracy;
|
||||
- bounded worker disconnect/recovery and authenticated non-SSH transport;
|
||||
- navigation or safety acceptance.
|
||||
|
||||
## Next gate — LAB E8
|
||||
|
||||
Move the same contract onto the real executor path without changing K1 command
|
||||
ownership:
|
||||
|
||||
1. replay encoded camera frames to the RTX worker at 10 Hz;
|
||||
2. run YOLOX detection/tracking without synchronous video-artifact writes;
|
||||
3. run semantic segmentation asynchronously at a lower measured rate and
|
||||
expire old masks explicitly;
|
||||
4. feed current detections, latest valid semantics and current LiDAR/pose into
|
||||
the E6 fusion adapter;
|
||||
5. publish the same E7 world-state schema through the capacity-two queue;
|
||||
6. measure transport, decode, inference, fusion and publication latency
|
||||
separately;
|
||||
7. repeat the 250 ms overload and worker-disconnect negative controls;
|
||||
8. connect the live K1 only after source-paced worker replay passes.
|
||||
|
||||
The initial E8 gate remains 5–10 Hz derived perception with p95 world-state age
|
||||
below 300 ms and uninterrupted raw recording. A visually better model is
|
||||
accepted only if it improves obstacle evidence inside that operational budget.
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
# LAB E8 — Source-paced RTX detector/tracker qualification
|
||||
|
||||
Date: 2026-07-21
|
||||
State: completed; qualification and overload-control results accepted
|
||||
Ground truth: no
|
||||
Navigation/safety acceptance: no
|
||||
|
||||
## Purpose
|
||||
|
||||
LAB E8 answers one narrow but essential autonomous-perception question:
|
||||
|
||||
> Can the existing RTX worker consume the K1 right-camera stream at its recorded
|
||||
> approximately 10 Hz rate, produce YOLOX detections and temporal tracks before
|
||||
> the next source deadlines, and remain bounded if processing becomes slower than
|
||||
> the source?
|
||||
|
||||
The lab deliberately removes synchronous overlay rendering and per-frame output
|
||||
images from the inference critical path. It does not improve playback video in
|
||||
isolation. It qualifies the detector/tracker branch that will feed the
|
||||
near-real-time fused world state.
|
||||
|
||||
The answer is **yes for this recorded 60-second qualification interval**. The
|
||||
worker processed 601/601 source frames with zero drops at 10.009945 effective
|
||||
FPS. Result-age p95 was 93.158479 ms. A separate forced-overload run proved that
|
||||
the queue remains bounded and discards superseded derived frames instead of
|
||||
building an unbounded latency tail.
|
||||
|
||||
## Input identity
|
||||
|
||||
- Compute job: `recorded-camera-602ac89026ed12978619801d`
|
||||
- Session: `RAVNOVES00`
|
||||
- Source: `sensor.camera.right`
|
||||
- Calibration slot: `camera_1`
|
||||
- Calibration content SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- Codec epoch: 1
|
||||
- Resolution: 800x600
|
||||
- Source selection: frames 1,000–1,600 inclusive
|
||||
- Frames scheduled: 601
|
||||
- Session timeline: 135.365857292–195.334857292 seconds
|
||||
- Source span: 59.969 seconds
|
||||
- Approximate source rate: 10.004 FPS
|
||||
|
||||
The interval is the same predeclared E5/E6/E7 qualification slice. It contains
|
||||
parked and moving vehicles, people at several scales, a close woman/child/stroller
|
||||
pass, fisheye distortion, occlusion and viewpoint change.
|
||||
|
||||
The valid-FOV mask is the immutable E1 result
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`.
|
||||
Its mask SHA-256 is
|
||||
`a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`.
|
||||
It admits 270,606 of 480,000 pixels and is loaded once per run.
|
||||
|
||||
## Worker and neighboring services
|
||||
|
||||
Read-only preflight immediately before implementation confirmed:
|
||||
|
||||
- host alias: `mission-gpu`;
|
||||
- Windows hostname: `<worker-host>`;
|
||||
- GPU: NVIDIA GeForce RTX 4090, 24,564 MiB;
|
||||
- driver: 610.47;
|
||||
- worker free space at preflight: 407,939,772,416 bytes;
|
||||
- `mission-core-triton`: healthy;
|
||||
- `sentinel-frigate`: healthy;
|
||||
- `sentinel-ollama`: running.
|
||||
|
||||
The qualification ran inside the existing
|
||||
`nvcr.io/nvidia/tritonserver:26.06-py3` image and used the existing D-backed
|
||||
Python environment. No task-controlled C: path was created, mounted or used.
|
||||
All run inputs, temporary files, model data and immutable evidence remained
|
||||
under `D:\NDC_MISSIONCORE`.
|
||||
|
||||
After all E8 runs and exact temporary cleanup:
|
||||
|
||||
- D: free bytes: 407,465,738,240 (approximately 379.37 GiB);
|
||||
- enforced floor: 386,547,056,640 bytes (360 GiB);
|
||||
- YOLOX returned to its pre-run unloaded state;
|
||||
- Triton, Frigate and Ollama remained running.
|
||||
|
||||
## Accepted software configuration
|
||||
|
||||
Detector and tracker configuration are inherited without threshold changes from
|
||||
accepted LAB E5:
|
||||
|
||||
- detector: official Megvii YOLOX-S ONNX release `0.1.1rc0`;
|
||||
- model SHA-256:
|
||||
`c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
|
||||
- Triton config SHA-256:
|
||||
`5795c737a7935a655961b069e8404d336d891f9762fb6dffb93956a076479604`;
|
||||
- Triton backend: ONNX Runtime GPU;
|
||||
- input: FP32 `[1,3,640,640]`, BGR, bilinear top-left letterbox, fill 114;
|
||||
- COCO classes admitted: person, bicycle, car, motorcycle, bus and truck;
|
||||
- detector minimum score: 0.10;
|
||||
- NMS IoU: 0.45;
|
||||
- nested-box containment suppression: 0.80;
|
||||
- ByteTrack-style, class-consistent, two-stage IoU association;
|
||||
- high/new-track threshold: 0.25;
|
||||
- primary/secondary match IoU: 0.20/0.10;
|
||||
- lost-track buffer: 15 processed frames;
|
||||
- minimum confirmation: two hits;
|
||||
- no appearance ReID and no camera-motion compensation.
|
||||
|
||||
Real-time contract:
|
||||
|
||||
- source pacing: exact recorded session timestamps at 1.0x;
|
||||
- queue: bounded latest-wins;
|
||||
- capacity: two decoded frames;
|
||||
- overflow action: discard the oldest unprocessed derived frame;
|
||||
- stale threshold: 150 ms from scheduled source time;
|
||||
- unavailable threshold: 500 ms;
|
||||
- synchronous overlays and video encoding: disabled;
|
||||
- output in the critical path: compact frame and telemetry JSONL only.
|
||||
|
||||
Accepted producer identities:
|
||||
|
||||
- runner SHA-256:
|
||||
`3eebf55479151788467af804c551b11660a551364c29b128a78e3ca7de36b2b2`;
|
||||
- orchestrator SHA-256:
|
||||
`a98990024a719b2ac1f0b75c0aea93d9ec18341be653f44594afff391b700f76`;
|
||||
- qualification profile SHA-256:
|
||||
`819bdfb0da521d187ec9ce3d3039a0ff75daa0ab473fb3d8e5fa92db9c1ddca1`;
|
||||
- overload profile SHA-256:
|
||||
`ac2ca648b46857953baa1bd6f07254bcc680c70566ed32d48b7431bf428bf757`;
|
||||
- independent result validator SHA-256:
|
||||
`32eb8f1d56b80ceac1a63b850767f3f7ccb1aea14b530412199f5f48f1c7be20`.
|
||||
|
||||
## Execution topology
|
||||
|
||||
```text
|
||||
recorded fMP4 camera epoch
|
||||
-> temporary exact PNG reconstruction on D:
|
||||
-> producer waits for each recorded source timestamp
|
||||
-> decode RGB + fixed valid-FOV fill
|
||||
-> bounded latest-wins queue (capacity 2)
|
||||
-> YOLOX-S Triton request
|
||||
-> YOLOX decode, FOV/class/NMS filtering
|
||||
-> ByteTrack-style temporal association
|
||||
-> compact timestamped result + freshness telemetry
|
||||
```
|
||||
|
||||
The producer and inference consumer run independently. The producer never waits
|
||||
for an unbounded queue. If the queue is full, the oldest unprocessed derived
|
||||
frame is replaced by the new frame. Raw source preservation is outside this
|
||||
drop policy; only recomputable perception preview work may be dropped.
|
||||
|
||||
The measured `result_age_ms` begins at the scheduled source time and ends after
|
||||
detection, association and result construction. It therefore includes source
|
||||
release lag, PNG decode, queue wait, preprocessing, Triton, postprocessing and
|
||||
tracking. It does not include the one-time job validation, model load, fMP4
|
||||
reconstruction or PNG extraction that occurs before the paced interval.
|
||||
|
||||
## Pilot gate
|
||||
|
||||
Pilot result:
|
||||
`e8-realtime-tracking-ba7f73c8f4e0bde1634cce89d7fda5c94c43d0b55af8080a1eeec8af636474ba`.
|
||||
|
||||
- selection: source frames 1,230–1,293;
|
||||
- source span: 6.296 seconds;
|
||||
- processed: 64/64;
|
||||
- dropped: 0;
|
||||
- effective rate: 10.052679 FPS;
|
||||
- queue maximum depth: 1/2;
|
||||
- result-age p95: 78.527889 ms;
|
||||
- health: 64 healthy;
|
||||
- accepted: yes.
|
||||
|
||||
The pilot proved the model, queue, timing basis, disk guard and publication
|
||||
contract before the full interval was admitted.
|
||||
|
||||
## Full qualification result
|
||||
|
||||
Published result:
|
||||
`e8-realtime-tracking-2802f407cb9d2c24576a4ba32d3e35f4d2b2092bfa99b37c438a8ac8120e1b16`.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Frames scheduled / processed | 601 / 601 |
|
||||
| Frames dropped / failed | 0 / 0 |
|
||||
| Source span | 59.969 s |
|
||||
| Paced replay wall | 60.040290 s |
|
||||
| Effective output rate | 10.009945 FPS |
|
||||
| Queue capacity / maximum depth | 2 / 1 |
|
||||
| Healthy results | 601 |
|
||||
| Stale / unavailable results | 0 / 0 |
|
||||
| Detections | 4,049 |
|
||||
| Unique confirmed track IDs | 167 |
|
||||
|
||||
All qualification checks passed:
|
||||
|
||||
- minimum 9.5 effective FPS;
|
||||
- maximum 0.5% drop fraction;
|
||||
- result-age p95 at or below 100 ms;
|
||||
- zero failures;
|
||||
- exact producer/consumer/drop accounting;
|
||||
- queue depth never above its declared capacity.
|
||||
|
||||
### Critical-path latency
|
||||
|
||||
| Stage | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Result age from scheduled source time | 66.719 ms | 62.533 ms | 93.158 ms | 145.973 ms |
|
||||
| Image decode | 32.693 ms | 30.396 ms | 48.431 ms | 70.110 ms |
|
||||
| Source release lag | 1.865 ms | 0.088 ms | 21.608 ms | 46.695 ms |
|
||||
| Queue wait | 0.090 ms | 0.064 ms | 0.121 ms | 8.993 ms |
|
||||
| Processing after dequeue | 32.070 ms | 30.770 ms | 43.526 ms | 83.312 ms |
|
||||
| Preprocess | 7.045 ms | 6.926 ms | 9.574 ms | 20.348 ms |
|
||||
| Triton request | 12.223 ms | 11.684 ms | 18.589 ms | 36.336 ms |
|
||||
| Detector postprocess | 8.813 ms | 8.729 ms | 15.167 ms | 28.547 ms |
|
||||
| Tracking | 0.305 ms | 0.253 ms | 0.638 ms | 2.210 ms |
|
||||
|
||||
The maximum result age crossed the 100 ms nominal frame period on isolated
|
||||
frames, but p95 remained below 100 ms and no queue accumulation or drop
|
||||
occurred. The current performance margin is usable for this 10 Hz source but is
|
||||
not large enough to add the E4 semantic model synchronously to every frame.
|
||||
|
||||
### GPU telemetry
|
||||
|
||||
The 61 one-second samples during the paced interval measured total board state,
|
||||
including neighboring services:
|
||||
|
||||
- GPU utilization: 52.08% mean, 57% p95, 63% max;
|
||||
- board memory used: 10,240.57 MiB mean, 10,250 MiB max;
|
||||
- power: 144.57 W mean, 147.49 W p95, 150.23 W max;
|
||||
- temperature: 42.95 C mean, 44 C max.
|
||||
|
||||
These are board totals, not per-process CUDA allocation claims.
|
||||
|
||||
## Forced-overload negative control
|
||||
|
||||
Published result:
|
||||
`e8-realtime-tracking-93ab963d9e4f25a93b60148a9a512e5ac7e1df3cdacec60ab105435b344fcf61`.
|
||||
|
||||
The same 64-frame pilot interval was replayed at 1.0x with an intentional
|
||||
140 ms consumer delay after every processed frame. This makes the consumer
|
||||
slower than the approximately 100 ms source period.
|
||||
|
||||
| Measurement | Result |
|
||||
|---|---:|
|
||||
| Frames published by source | 64 |
|
||||
| Frames processed | 40 |
|
||||
| Superseded frames dropped | 24 (37.5%) |
|
||||
| Effective output rate | 5.895414 FPS |
|
||||
| Queue capacity / maximum depth | 2 / 2 |
|
||||
| Result-age p95 | 442.345170 ms |
|
||||
| Health | 40 stale |
|
||||
|
||||
This is the intended failure behavior: derived perception loses temporal
|
||||
resolution and declares staleness, but memory/latency does not grow without a
|
||||
bound. The result is accepted only as an overload-control proof, not as an
|
||||
operational performance result.
|
||||
|
||||
## Comparison with LAB E5
|
||||
|
||||
LAB E5 processed the same 601 frames sequentially while writing a PNG overlay
|
||||
for every frame. Its inference/tracking/artifact loop achieved 6.822348 FPS and
|
||||
had 146.279 ms mean end-to-end time. The synchronous overlay write alone cost
|
||||
87.363 ms mean.
|
||||
|
||||
LAB E8 removes that visualization work from the inference critical path:
|
||||
|
||||
- 10.009945 FPS instead of 6.822348 FPS;
|
||||
- 32.070 ms mean dequeue-to-result processing;
|
||||
- 12.223 ms mean Triton request;
|
||||
- 0.305 ms mean tracking;
|
||||
- no source frame drops in the 60-second qualification.
|
||||
|
||||
This proves that LAB E5's offline rate was not the detector/tracker ceiling.
|
||||
The remaining largest per-frame cost in E8 is temporary PNG decode. Direct
|
||||
camera/video ingest can reduce it later, but it is not required to meet the
|
||||
present 10 Hz gate.
|
||||
|
||||
## Disk policy and measured usage
|
||||
|
||||
The full qualification reserved a conservative 3.273 GiB working set and
|
||||
failed closed if the 360 GiB D: floor would be crossed.
|
||||
|
||||
| Disk point | Free bytes | Free GiB |
|
||||
|---|---:|---:|
|
||||
| Before qualification | 407,538,298,880 | 379.550 |
|
||||
| After temporary frame extraction | 406,955,905,024 | 379.007 |
|
||||
| After paced replay | 406,868,586,496 | 378.926 |
|
||||
| After immutable publication, before temp cleanup | 406,868,582,400 | 378.926 |
|
||||
| After all E8 runs and exact temp cleanup | 407,465,738,240 | 379.370 |
|
||||
| Enforced floor | 386,547,056,640 | 360.000 |
|
||||
|
||||
No unrelated data was removed. The immutable pilot, qualification, overload
|
||||
control and staged runner bundles remain on D: as evidence.
|
||||
|
||||
## Artifact integrity
|
||||
|
||||
The full result contains:
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `frames.jsonl` | 1,156,936 | `df5fe55f52df887c8d04e1ed3937f0faf5b08d379b6f41e45638016f1bd2a479` |
|
||||
| `telemetry.jsonl` | 145,235 | `8d904465fd8a352d59a62bb7ecdeebdc0c5ea07b1700c95136e14a197c617182` |
|
||||
| `gpu-telemetry.jsonl` | 13,652 | `6d87dcf9944831ad5bd9bb1c88381ac940ff30d175475068891893de1e7dcdb4` |
|
||||
| `run-report.json` | 9,478 | `6fb136fa54ebf7d655b559da3218aacab1aa5838308d5ed83211081857f8e2f5` |
|
||||
|
||||
Independent host validation confirmed for pilot, full qualification and
|
||||
overload control:
|
||||
|
||||
- result directory equals the canonical identity SHA-256;
|
||||
- every declared artifact size and SHA-256 matches;
|
||||
- result identity binds job, input, session, source, selection, calibration,
|
||||
model, profile, runner and orchestrator;
|
||||
- all frame and telemetry rows are strictly ordered and mutually bound;
|
||||
- full qualification contains all 601 source indices;
|
||||
- overload-control gaps exactly account for the 24 latest-wins drops;
|
||||
- all detection and track boxes are finite and confined to 800x600;
|
||||
- queue producer, consumer and drop totals are exact;
|
||||
- all three immutable results pass the dedicated E8 validator.
|
||||
|
||||
Durable local evidence root:
|
||||
|
||||
```text
|
||||
.runtime/compute-experiments/e8/worker-results/
|
||||
```
|
||||
|
||||
Worker evidence roots:
|
||||
|
||||
```text
|
||||
D:\NDC_MISSIONCORE\runtime\derived\
|
||||
e8-realtime-tracking-ba7f73c8f4e0bde1634cce89d7fda5c94c43d0b55af8080a1eeec8af636474ba
|
||||
e8-realtime-tracking-2802f407cb9d2c24576a4ba32d3e35f4d2b2092bfa99b37c438a8ac8120e1b16
|
||||
e8-realtime-tracking-93ab963d9e4f25a93b60148a9a512e5ac7e1df3cdacec60ab105435b344fcf61
|
||||
```
|
||||
|
||||
## What is accepted
|
||||
|
||||
- actual YOLOX-S inference on the RTX worker at the recorded 10 Hz source rate;
|
||||
- temporal tracking in the same source-paced critical path;
|
||||
- fixed calibration-bound valid-FOV preprocessing;
|
||||
- 601/601 processed frames, zero drops and p95 below 100 ms;
|
||||
- explicit health/freshness state;
|
||||
- bounded latest-wins overload behavior;
|
||||
- D-only task-controlled storage and exact temporary cleanup;
|
||||
- restoration of the pre-run Triton model state;
|
||||
- immutable, content-addressed evidence and independent validation.
|
||||
|
||||
## What is not accepted
|
||||
|
||||
- direct live K1 network/camera transport;
|
||||
- real-time semantic segmentation;
|
||||
- live LiDAR association, 3D cuboids or metric range in this worker run;
|
||||
- appearance ReID or camera-motion compensation;
|
||||
- human-reviewed accuracy, IDF1/HOTA/MOTA or safety metrics;
|
||||
- navigation or actuation use;
|
||||
- production transport between the worker and Mission Core;
|
||||
- evidence that this exact margin holds for arbitrarily long sessions.
|
||||
|
||||
## Next gate
|
||||
|
||||
The next experiment must preserve this accepted detector/tracker path and add
|
||||
semantics as a separate lower-rate asynchronous branch:
|
||||
|
||||
1. keep detector/tracker at the 10 Hz source cadence;
|
||||
2. schedule semantic inference at a lower rate with a capacity-one latest-wins
|
||||
queue;
|
||||
3. attach each semantic result to its exact source timestamp;
|
||||
4. reuse a semantic result only within an explicit TTL and expose its age;
|
||||
5. never block detector/tracker or raw recording on semantic completion;
|
||||
6. measure GPU contention against the accepted E8 baseline;
|
||||
7. merge fresh tracking, semantics and existing E6 LiDAR geometry into the E7
|
||||
world-state contract;
|
||||
8. qualify overload and semantic-worker loss before any live K1 trial.
|
||||
|
||||
Ops publication remains pending because this Codex session did not expose the
|
||||
required direct `nodedc-ops-agent` tools. No legacy Ops path was used.
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
# LAB E9 — Concurrent multirate detector/tracker and semantics
|
||||
|
||||
Date: 2026-07-21
|
||||
State: completed; full qualification accepted
|
||||
Ground truth: no
|
||||
Navigation/safety acceptance: no
|
||||
|
||||
## Purpose and answer
|
||||
|
||||
LAB E9 tests the next autonomous-perception requirement after E8:
|
||||
|
||||
> Can the RTX worker preserve the approximately 10 Hz detector/tracker path
|
||||
> while running the existing EoMT semantic model concurrently at a lower rate,
|
||||
> with independent bounded queues and explicit semantic freshness?
|
||||
|
||||
The answer is **yes for the accepted recorded 60-second interval**:
|
||||
|
||||
- detector/tracker: 601/601 frames, 0 drops, 9.984702 FPS;
|
||||
- detector result-age p95: 92.583758 ms;
|
||||
- semantics: 121/121 scheduled frames, 0 drops, 2.010231 FPS;
|
||||
- semantic completion-age p95: 250.404840 ms;
|
||||
- fresh semantic binding: 597/601 detector states (99.3344%);
|
||||
- detector queue maximum: 1/2;
|
||||
- semantic queue maximum: 1/1.
|
||||
|
||||
This is a concurrent worker measurement, not an offline composition of
|
||||
precomputed E4 and E5 artifacts. Both YOLOX/Triton and EoMT/PyTorch executed on
|
||||
the RTX 4090 during the source-paced interval.
|
||||
|
||||
## Input identity
|
||||
|
||||
- Compute job: `recorded-camera-602ac89026ed12978619801d`
|
||||
- Session: `RAVNOVES00`
|
||||
- Source: `sensor.camera.right`
|
||||
- Calibration slot: `camera_1`
|
||||
- Calibration content SHA-256:
|
||||
`05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9`
|
||||
- Resolution: 800x600
|
||||
- Full qualification selection: source frames 1,000–1,600 inclusive
|
||||
- Detector frames scheduled: 601
|
||||
- Semantic stride: every fifth source frame
|
||||
- Semantic frames scheduled: 121
|
||||
- Session timeline: 135.365857292–195.334857292 seconds
|
||||
- Source span: 59.969 seconds
|
||||
|
||||
The immutable valid-FOV mask is
|
||||
`valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2`.
|
||||
Mask SHA-256:
|
||||
`a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63`.
|
||||
|
||||
## Models and runtime
|
||||
|
||||
Detector/tracker branch:
|
||||
|
||||
- official Megvii YOLOX-S ONNX release `0.1.1rc0`;
|
||||
- model SHA-256:
|
||||
`c5c2d13e59ae883e6af3b45daea64af4833a4951c92d116ec270d9ddbe998063`;
|
||||
- ONNX Runtime GPU through the existing Triton container;
|
||||
- accepted E8 detector profile SHA-256:
|
||||
`819bdfb0da521d187ec9ce3d3039a0ff75daa0ab473fb3d8e5fa92db9c1ddca1`;
|
||||
- ByteTrack-style two-stage IoU tracking, unchanged from E5/E8.
|
||||
|
||||
Semantic branch:
|
||||
|
||||
- `tue-mps/cityscapes_semantic_eomt_large_1024`;
|
||||
- revision: `8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f`;
|
||||
- weights: 1,276,175,488 bytes;
|
||||
- model SHA-256:
|
||||
`c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782`;
|
||||
- precision: FP16 autocast, batch size one;
|
||||
- accepted E4 semantic profile SHA-256:
|
||||
`ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`;
|
||||
- Cityscapes output mapped into the existing Mission Core 0–15 taxonomy.
|
||||
|
||||
Worker runtime:
|
||||
|
||||
- NVIDIA GeForce RTX 4090;
|
||||
- Python 3.12.3;
|
||||
- PyTorch 2.13.0+cu130;
|
||||
- Transformers 4.57.6;
|
||||
- NumPy 1.26.4;
|
||||
- SciPy 1.16.3;
|
||||
- container: `nvcr.io/nvidia/tritonserver:26.06-py3`.
|
||||
|
||||
Accepted producer identities:
|
||||
|
||||
- runner SHA-256:
|
||||
`89e3e6de6ed3d6c70687927d6b55335399ac5af83f60e41b09b8b1936def390f`;
|
||||
- orchestrator SHA-256:
|
||||
`ea4351d8761fa03c53d434b35ee64d56c12baec6a18d5cdf2ae7c0b7b461c655`;
|
||||
- accepted multirate profile SHA-256:
|
||||
`4c6a68e5be69b2140cd9efbcfdbc76a97d382a1de3822eb10ffcf47f94a2108e`;
|
||||
- independent validator SHA-256:
|
||||
`ba796085856c91918dcb5d3e3b48ccf4989354da40ccdbbe6d936319e0cfedf2`.
|
||||
|
||||
## Scheduling contract
|
||||
|
||||
```text
|
||||
recorded source at exact session timestamps
|
||||
├─ detector queue: capacity 2, every frame (~10 Hz)
|
||||
│ -> valid-FOV -> YOLOX/Triton -> tracking
|
||||
│
|
||||
└─ semantic queue: capacity 1, every fifth frame (~2 Hz)
|
||||
-> valid-FOV -> EoMT/PyTorch -> semantic mask identity/classes
|
||||
|
||||
detector result
|
||||
+ newest completed semantic result whose source timestamp is not future
|
||||
-> semantic status: fresh / stale / unavailable
|
||||
```
|
||||
|
||||
Both queues use latest-wins overflow behavior. A slow semantic inference cannot
|
||||
block detector admission, raw recording or source timing. Semantic TTL is
|
||||
750 ms. Before the first semantic result completes, merged detector states are
|
||||
explicitly `unavailable`; no mask is invented. Afterward, a mask is `fresh`
|
||||
only while its source age is within TTL.
|
||||
|
||||
The paced interval begins only after both models are loaded and warmed. The
|
||||
orchestrator intentionally performs full model/hash preflight and then launches
|
||||
an isolated run container, so the laboratory wall time includes two EoMT loads.
|
||||
A persistent production worker would load once. Startup is reported separately
|
||||
from frame latency and is not hidden inside the 60-second replay metrics.
|
||||
|
||||
## Pilot history and profile freeze
|
||||
|
||||
### Pilot 1 — rejected strict budget
|
||||
|
||||
Result:
|
||||
`e9-multirate-perception-4dba9d71ee14e8be6d66f4ae0a18d31c0be7d442039d400a3d8fd5b4396a3af3`.
|
||||
|
||||
The first 64-frame pilot required detector result-age p95 at or below 100 ms.
|
||||
It produced:
|
||||
|
||||
- detector: 64/64, 0 drops, 10.041497 FPS;
|
||||
- detector result-age p95: 121.054151 ms, max 126.765087 ms;
|
||||
- semantics: 13/13, 0 drops, 2.039679 FPS;
|
||||
- semantic completion-age p95: 244.136232 ms;
|
||||
- fresh coverage: 92.1875%;
|
||||
- queue maxima: detector 1/2, semantic 1/1.
|
||||
|
||||
Only the detector p95 check failed. GPU contention was measured directly:
|
||||
Triton request p95 rose from E8's 18.589 ms to 32.569 ms while the concurrent
|
||||
EoMT forward pass occupied the GPU. No queue accumulation, drop, stale detector
|
||||
state or >150 ms result occurred.
|
||||
|
||||
The rejected result remains immutable evidence. It was not retroactively
|
||||
accepted.
|
||||
|
||||
### Pilot 2 — accepted multirate budget
|
||||
|
||||
The second profile defines a multirate detector p95 ceiling of 150 ms, matching
|
||||
the already established E8 stale boundary. This is not a safety claim; it is an
|
||||
explicit near-real-time laboratory budget for the combined workload.
|
||||
|
||||
Result:
|
||||
`e9-multirate-perception-51b279ffa70c9ce98e9bd41498821aa527022effe3bcf5fa0b0fac9c5f31cfae`.
|
||||
|
||||
- detector: 64/64, 0 drops, 10.074834 FPS;
|
||||
- detector result-age p95: 104.053520 ms;
|
||||
- semantics: 13/13, 0 drops, 2.046451 FPS;
|
||||
- semantic completion-age p95: 244.872902 ms;
|
||||
- fresh coverage: 93.75%;
|
||||
- accepted.
|
||||
|
||||
The v2 profile was frozen before the full run. No threshold was changed after
|
||||
seeing full-run output.
|
||||
|
||||
## Full qualification
|
||||
|
||||
Published result:
|
||||
`e9-multirate-perception-161e0601d2a344e0a48c2498b68cefcac340e0afb77e4a45fecfa05bb44e4f2a`.
|
||||
|
||||
| Measurement | Detector/tracker | Semantics |
|
||||
|---|---:|---:|
|
||||
| Scheduled | 601 | 121 |
|
||||
| Processed | 601 | 121 |
|
||||
| Dropped | 0 | 0 |
|
||||
| Effective rate | 9.984702 FPS | 2.010231 FPS |
|
||||
| Queue capacity / max | 2 / 1 | 1 / 1 |
|
||||
| Result/completion age p95 | 92.583758 ms | 250.404840 ms |
|
||||
| Result/completion age max | 139.250452 ms | 443.920248 ms |
|
||||
|
||||
Merged semantic binding:
|
||||
|
||||
- fresh: 597 detector states;
|
||||
- unavailable during startup: 4 detector states;
|
||||
- stale: 0;
|
||||
- fresh coverage: 99.3344%.
|
||||
|
||||
Detector output remained consistent with E5/E8:
|
||||
|
||||
- detections: 4,049;
|
||||
- unique confirmed track IDs: 167;
|
||||
- confirmed track observations: 3,320;
|
||||
- same-class duplicate pairs at IoU >= 0.8: 0.
|
||||
|
||||
### Detector critical-path latency
|
||||
|
||||
| Stage | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Result age from source deadline | 64.529 ms | 61.538 ms | 92.584 ms | 139.250 ms |
|
||||
| Decode | 31.736 ms | 29.785 ms | 44.604 ms | 62.679 ms |
|
||||
| Queue wait | 0.175 ms | 0.073 ms | 0.183 ms | 16.178 ms |
|
||||
| Processing after dequeue | 30.839 ms | 28.704 ms | 46.099 ms | 76.031 ms |
|
||||
| Triton request | 16.898 ms | 14.462 ms | 30.324 ms | 39.523 ms |
|
||||
| Tracking | 0.379 ms | 0.286 ms | 0.909 ms | 4.888 ms |
|
||||
|
||||
### Semantic latency
|
||||
|
||||
| Stage | Mean | p50 | p95 | Max |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Completion age from source deadline | 216.777 ms | 212.143 ms | 250.405 ms | 443.920 ms |
|
||||
| Processing after dequeue | 183.485 ms | 180.182 ms | 199.038 ms | 413.802 ms |
|
||||
| EoMT forward | 150.739 ms | 149.165 ms | 162.677 ms | 376.747 ms |
|
||||
| Processor | 12.344 ms | 11.261 ms | 20.817 ms | 47.351 ms |
|
||||
| Host to device | 7.952 ms | 7.730 ms | 11.735 ms | 15.397 ms |
|
||||
| Model postprocess | 9.324 ms | 9.145 ms | 13.969 ms | 20.305 ms |
|
||||
| Queue wait | 0.192 ms | 0.170 ms | 0.277 ms | 1.786 ms |
|
||||
|
||||
## GPU and memory
|
||||
|
||||
Across 61 one-second samples during the measured interval:
|
||||
|
||||
- GPU utilization: 80.21% mean, 93% p95, 94% max;
|
||||
- board memory used: 13,558.15 MiB mean, 13,568 MiB max;
|
||||
- power: 200.66 W mean, 210.16 W p95, 225.70 W max;
|
||||
- temperature: 47.11 C mean, 52 C max;
|
||||
- EoMT process CUDA peak allocated: 2,107.925 MiB;
|
||||
- EoMT process CUDA peak reserved: 2,840.0 MiB;
|
||||
- process peak RSS: 2,007.344 MiB.
|
||||
|
||||
Board memory includes Triton and neighboring services. The high 93% p95 GPU
|
||||
utilization is important: the accepted configuration has limited compute
|
||||
headroom. Increasing semantic frequency or adding another large model cannot be
|
||||
assumed safe without a new experiment.
|
||||
|
||||
## Disk and service safety
|
||||
|
||||
All task-controlled paths were confined to `D:\NDC_MISSIONCORE`. The full run
|
||||
reserved 4.273 GiB and enforced the 360 GiB free-space floor.
|
||||
|
||||
| Disk point | Free bytes | Free GiB |
|
||||
|---|---:|---:|
|
||||
| Before full run | 406,969,856,000 | 379.020 |
|
||||
| After temporary frame extraction | 406,493,011,968 | 378.576 |
|
||||
| After replay/publication, before temp cleanup | 406,374,842,368 | 378.466 |
|
||||
| After all E9 cleanup/evidence transfer | 406,836,731,904 | 378.896 |
|
||||
| Enforced floor | 386,547,056,640 | 360.000 |
|
||||
|
||||
The temporary stream and PNG frame set were removed by exact run-owned path.
|
||||
No unrelated data was removed. YOLOX was restored to its pre-run unloaded
|
||||
state. Triton and Frigate remained healthy; Ollama remained running.
|
||||
|
||||
## Artifacts and integrity
|
||||
|
||||
| Artifact | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `detector-frames.jsonl` | 1,115,551 | `c17a82bc71abe5deb2542f9df257cbe52fa2ae88b1c4ae1986587e207b2243e0` |
|
||||
| `semantic-frames.jsonl` | 87,225 | `6da2beca271922ebd9491ccef35e833c6d607387bcde816045a298cedb9a9f45` |
|
||||
| `merged-frames.jsonl` | 689,114 | `c9a6c60df8c646067f988e17fe2a7b1b943200e88c5caaf4dca400c24cd5d5b3` |
|
||||
| `gpu-telemetry.jsonl` | 13,710 | `7390a32aa924cbe87a5b0beab261de9429712db4794a25d77dfa6b171b33b6da` |
|
||||
| `run-report.json` | 11,605 | `4be352a59665d1fe71630894a8a9324d5f27dde934650f65cef4305ac610a4e6` |
|
||||
|
||||
The semantic mask itself is held in memory in this scheduling gate. Each
|
||||
semantic row records the exact source frame, completion age, taxonomy counts
|
||||
and SHA-256 of the 800x600 target mask. Mask images are not synchronously written
|
||||
and therefore cannot distort the measured critical path.
|
||||
|
||||
Independent validation confirmed for the rejected pilot, accepted pilot and
|
||||
accepted full result:
|
||||
|
||||
- content-addressed identity and all artifact hashes;
|
||||
- exact job/session/source/calibration/model/profile/runner binding;
|
||||
- detector and semantic queue accounting;
|
||||
- ordered detector and semantic source timestamps;
|
||||
- semantic frames occur only on the declared stride;
|
||||
- merged semantics never come from a future source frame;
|
||||
- unavailable bindings contain no invented mask data;
|
||||
- all three results pass the dedicated E9 validator.
|
||||
|
||||
Durable local evidence root:
|
||||
|
||||
```text
|
||||
.runtime/compute-experiments/e9/worker-results/
|
||||
```
|
||||
|
||||
Worker full-result root:
|
||||
|
||||
```text
|
||||
D:\NDC_MISSIONCORE\runtime\derived\
|
||||
e9-multirate-perception-161e0601d2a344e0a48c2498b68cefcac340e0afb77e4a45fecfa05bb44e4f2a
|
||||
```
|
||||
|
||||
## Accepted scope
|
||||
|
||||
- actual concurrent YOLOX/Triton and EoMT/PyTorch execution on the RTX worker;
|
||||
- detector/tracker near 10 Hz with no drops;
|
||||
- semantics near 2 Hz with no drops;
|
||||
- independent bounded latest-wins queues;
|
||||
- exact semantic source binding, TTL and explicit unavailable state;
|
||||
- 60-second pacing, latency, GPU, memory and disk evidence;
|
||||
- D-only task-controlled storage and restored model/service state.
|
||||
|
||||
## Not accepted
|
||||
|
||||
- live K1 transport or arbitrarily long continuous operation;
|
||||
- semantic mask delivery into Mission Core/Rerun;
|
||||
- live LiDAR fusion, metric distance or 3D cuboids in this concurrent run;
|
||||
- domain accuracy for forest/off-road navigation;
|
||||
- optimized semantic model, TensorRT semantic deployment or extra GPU headroom;
|
||||
- navigation or safety use.
|
||||
|
||||
## Next gate
|
||||
|
||||
The next step is integration rather than another isolated AI benchmark:
|
||||
|
||||
1. carry the latest in-memory semantic mask and exact freshness into the E7
|
||||
world-state publisher;
|
||||
2. run existing E6 LiDAR association/cuboids against the live 10 Hz tracking
|
||||
branch;
|
||||
3. show transient 2D segmentation plus 3D objects/range in Rerun without writing
|
||||
per-frame PNGs;
|
||||
4. inject semantic-worker loss and verify tracking/LiDAR continue with explicit
|
||||
degraded state;
|
||||
5. only then replace recorded pacing with direct K1/worker transport.
|
||||
|
||||
Ops publication remains pending because this Codex session exposes no direct
|
||||
`nodedc-ops-agent` tasker tools. No legacy Ops writer was used.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"association": {
|
||||
"box_inset": {
|
||||
"bottom_fraction": 0.02,
|
||||
"horizontal_fraction": 0.05,
|
||||
"top_fraction": 0.04
|
||||
},
|
||||
"depth_cluster_gap_fraction": 0.06,
|
||||
"depth_cluster_minimum_gap_m": 0.5,
|
||||
"group_nms_iou_threshold": 0.55,
|
||||
"maximum_cuboid_span_m": 12.0,
|
||||
"maximum_distance_innovation_fraction": 0.25,
|
||||
"maximum_distance_innovation_m": 1.5,
|
||||
"maximum_oriented_extent_m": {
|
||||
"bicycle": [3.5, 2.0, 2.5],
|
||||
"motorcycle": [3.5, 2.0, 2.5],
|
||||
"person": [1.5, 1.5, 2.8],
|
||||
"vehicle": [6.5, 3.5, 4.0]
|
||||
},
|
||||
"minimum_cuboid_extent_m": 0.15,
|
||||
"minimum_support_points": {
|
||||
"bicycle": 3,
|
||||
"motorcycle": 3,
|
||||
"person": 3,
|
||||
"vehicle": 5
|
||||
},
|
||||
"semantic_ids": {
|
||||
"bicycle": [2],
|
||||
"motorcycle": [3],
|
||||
"person": [1],
|
||||
"vehicle": [4, 5]
|
||||
},
|
||||
"spatial_cluster_radius_m": {
|
||||
"bicycle": 0.9,
|
||||
"motorcycle": 0.9,
|
||||
"person": 0.9,
|
||||
"vehicle": 1.5
|
||||
},
|
||||
"vehicle_labels": ["car", "truck", "bus"]
|
||||
},
|
||||
"inputs": {
|
||||
"semantic_result_id": "result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30",
|
||||
"tracking_result_id": "e5-tracking-8035c0013e36a7cfb68aa3652b05ab79fe36baf136462d5fbf91b348ee85ec08"
|
||||
},
|
||||
"presentation": {
|
||||
"contact_sheet_columns": 3,
|
||||
"contact_sheet_frames": 6,
|
||||
"distance_history_frames": 5,
|
||||
"jpeg_quality": 85,
|
||||
"overlay_crf": 20,
|
||||
"point_radius_ui": 1.5,
|
||||
"support_radius_ui": 3.0
|
||||
},
|
||||
"projection": {
|
||||
"occlusion_policy": "nearest-depth-per-rounded-pixel",
|
||||
"pixel_rounding": "nearest",
|
||||
"source_coordinates": "k1-map-frame",
|
||||
"visible_point_policy": "camera-front-in-frame"
|
||||
},
|
||||
"schema_version": "missioncore.e6-tracking-lidar-profile/v1",
|
||||
"source": {
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
"calibration_slot": "camera_1",
|
||||
"resolution": [800, 600],
|
||||
"source_id": "sensor.camera.right"
|
||||
},
|
||||
"temporal": {
|
||||
"binding": "nearest-host-arrival-best-effort",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"association": {
|
||||
"box_inset": {
|
||||
"bottom_fraction": 0.02,
|
||||
"horizontal_fraction": 0.05,
|
||||
"top_fraction": 0.04
|
||||
},
|
||||
"depth_cluster_gap_fraction": 0.06,
|
||||
"depth_cluster_minimum_gap_m": 0.5,
|
||||
"group_nms_iou_threshold": 0.55,
|
||||
"maximum_cuboid_span_m": 12.0,
|
||||
"maximum_distance_innovation_fraction": 0.25,
|
||||
"maximum_distance_innovation_m": 1.5,
|
||||
"maximum_oriented_extent_m": {
|
||||
"bicycle": [3.5, 2.0, 2.5],
|
||||
"motorcycle": [3.5, 2.0, 2.5],
|
||||
"person": [1.5, 1.5, 2.8],
|
||||
"vehicle": [6.5, 3.5, 4.0]
|
||||
},
|
||||
"minimum_cuboid_extent_m": 0.15,
|
||||
"minimum_support_points": {
|
||||
"bicycle": 3,
|
||||
"motorcycle": 3,
|
||||
"person": 3,
|
||||
"vehicle": 5
|
||||
},
|
||||
"semantic_ids": {
|
||||
"bicycle": [2],
|
||||
"motorcycle": [3],
|
||||
"person": [1],
|
||||
"vehicle": [4, 5]
|
||||
},
|
||||
"spatial_cluster_radius_m": {
|
||||
"bicycle": 0.9,
|
||||
"motorcycle": 0.9,
|
||||
"person": 0.9,
|
||||
"vehicle": 1.5
|
||||
},
|
||||
"vehicle_labels": ["car", "truck", "bus"]
|
||||
},
|
||||
"inputs": {
|
||||
"semantic_result_id": "result-793785170472c519486ccd666be102fb04d169d92383acda3fcc29eecf045d30",
|
||||
"tracking_result_id": "e5-tracking-88aace13ef9963f8dc07f85228e530f9d28c2b49aca9192409f7975512b058f6"
|
||||
},
|
||||
"presentation": {
|
||||
"contact_sheet_columns": 3,
|
||||
"contact_sheet_frames": 6,
|
||||
"distance_history_frames": 5,
|
||||
"jpeg_quality": 85,
|
||||
"overlay_crf": 20,
|
||||
"point_radius_ui": 1.5,
|
||||
"support_radius_ui": 3.0
|
||||
},
|
||||
"projection": {
|
||||
"occlusion_policy": "nearest-depth-per-rounded-pixel",
|
||||
"pixel_rounding": "nearest",
|
||||
"source_coordinates": "k1-map-frame",
|
||||
"visible_point_policy": "camera-front-in-frame"
|
||||
},
|
||||
"schema_version": "missioncore.e6-tracking-lidar-profile/v1",
|
||||
"source": {
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
"calibration_slot": "camera_1",
|
||||
"resolution": [800, 600],
|
||||
"source_id": "sensor.camera.right"
|
||||
},
|
||||
"temporal": {
|
||||
"binding": "nearest-host-arrival-best-effort",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"schema_version": "missioncore.e7-replay-realtime-profile/v1",
|
||||
"mode": "overload-negative-control",
|
||||
"selection": {
|
||||
"start_frame_index": 120,
|
||||
"frame_count": 80
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"queue_capacity": 2,
|
||||
"consumer_delay_ms": 250.0
|
||||
},
|
||||
"health": {
|
||||
"stale_after_ms": 300.0,
|
||||
"unavailable_after_ms": 1000.0
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"log_point_cloud": false,
|
||||
"point_radius_ui": 1.25
|
||||
},
|
||||
"acceptance": {
|
||||
"minimum_drop_fraction": 0.25,
|
||||
"maximum_queue_depth": 2,
|
||||
"maximum_process_rss_mib": 512.0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "missioncore.e7-replay-realtime-profile/v1",
|
||||
"mode": "pilot",
|
||||
"selection": {
|
||||
"start_frame_index": 120,
|
||||
"frame_count": 64
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"queue_capacity": 2,
|
||||
"consumer_delay_ms": 0.0
|
||||
},
|
||||
"health": {
|
||||
"stale_after_ms": 300.0,
|
||||
"unavailable_after_ms": 1000.0
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"log_point_cloud": true,
|
||||
"point_radius_ui": 1.25
|
||||
},
|
||||
"acceptance": {
|
||||
"minimum_delivery_rate_hz": 9.0,
|
||||
"maximum_end_to_end_p95_ms": 300.0,
|
||||
"maximum_drop_fraction": 0.02,
|
||||
"maximum_queue_depth": 2,
|
||||
"maximum_process_rss_mib": 512.0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"schema_version": "missioncore.e7-replay-realtime-profile/v1",
|
||||
"mode": "qualification",
|
||||
"selection": {
|
||||
"start_frame_index": 0,
|
||||
"frame_count": 601
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"queue_capacity": 2,
|
||||
"consumer_delay_ms": 0.0
|
||||
},
|
||||
"health": {
|
||||
"stale_after_ms": 300.0,
|
||||
"unavailable_after_ms": 1000.0
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"log_point_cloud": true,
|
||||
"point_radius_ui": 1.25
|
||||
},
|
||||
"acceptance": {
|
||||
"minimum_delivery_rate_hz": 9.5,
|
||||
"maximum_end_to_end_p95_ms": 300.0,
|
||||
"maximum_drop_fraction": 0.01,
|
||||
"maximum_queue_depth": 2,
|
||||
"maximum_process_rss_mib": 512.0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,543 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fuse accepted recorded image masks into calibrated K1 LiDAR observations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import rerun as rr
|
||||
from PIL import Image, ImageDraw
|
||||
from rerun.components import FillMode
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_overlay import (
|
||||
_extract_camera_frames,
|
||||
_load_calibration_snapshot,
|
||||
_select_camera_anchors,
|
||||
_select_lidar_samples,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
Kb4ProjectionProfile,
|
||||
map_points_to_lidar,
|
||||
project_map_points_kb4,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
|
||||
from k1link.sessions import inspect_recorded_media_epoch
|
||||
|
||||
SCHEMA = "missioncore.calibrated-segmentation-fusion-experiment/v1"
|
||||
ACCEPTED_INSTANCE_MODEL = "torchvision/maskrcnn_resnet50_fpn_v2"
|
||||
ACCEPTED_SEMANTIC_MODEL = "microsoft/beit-base-finetuned-ade-640-640"
|
||||
MIN_BOX_POINTS = 4
|
||||
MAX_BOX_SPAN_M = 12.0
|
||||
|
||||
FloatArray = npt.NDArray[np.float64]
|
||||
RgbImage = npt.NDArray[np.uint8]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObjectFusion:
|
||||
instance_id: int
|
||||
label: str
|
||||
score: float
|
||||
candidate_points: int
|
||||
clustered_points: int
|
||||
distance_p10_m: float | None
|
||||
distance_median_m: float | None
|
||||
box_center_map: tuple[float, float, float] | None
|
||||
box_half_size_map: tuple[float, float, float] | None
|
||||
box_status: str
|
||||
source_indices: npt.NDArray[np.int64]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FusedFrame:
|
||||
stem: str
|
||||
session_time_seconds: float
|
||||
image_rgb: RgbImage
|
||||
semantic_overlay_rgb: RgbImage
|
||||
fusion_overlay_rgb: RgbImage
|
||||
points_map: FloatArray
|
||||
projected_source_indices: npt.NDArray[np.int64]
|
||||
projected_pixels: FloatArray
|
||||
semantic_colors: RgbImage
|
||||
objects: tuple[ObjectFusion, ...]
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--session", type=Path, required=True)
|
||||
parser.add_argument("--calibration", type=Path, required=True)
|
||||
parser.add_argument("--calibrated-manifest", type=Path, required=True)
|
||||
parser.add_argument("--segmentation", type=Path, 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_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError(f"{path.name} is not a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
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 _palette(index: int) -> tuple[int, int, int]:
|
||||
digest = hashlib.sha256(f"mission-core-segment-{index}".encode()).digest()
|
||||
return (64 + digest[0] % 176, 64 + digest[1] % 176, 64 + digest[2] % 176)
|
||||
|
||||
|
||||
def _depth_cluster(indices: npt.NDArray[np.int64], depths: FloatArray) -> npt.NDArray[np.int64]:
|
||||
if indices.size < 2:
|
||||
return indices
|
||||
order = np.argsort(depths[indices])
|
||||
ordered = indices[order]
|
||||
ordered_depths = depths[ordered]
|
||||
groups: list[npt.NDArray[np.int64]] = []
|
||||
start = 0
|
||||
for offset, gap in enumerate(np.diff(ordered_depths), start=1):
|
||||
threshold = max(0.6, 0.08 * float(ordered_depths[offset - 1]))
|
||||
if float(gap) > threshold:
|
||||
groups.append(ordered[start:offset])
|
||||
start = offset
|
||||
groups.append(ordered[start:])
|
||||
return min(
|
||||
groups,
|
||||
key=lambda group: (-int(group.size), float(np.median(depths[group]))),
|
||||
)
|
||||
|
||||
|
||||
def _object_fusions(
|
||||
*,
|
||||
instance_map: npt.NDArray[np.uint16],
|
||||
instances: list[dict[str, Any]],
|
||||
pixel_x: npt.NDArray[np.int64],
|
||||
pixel_y: npt.NDArray[np.int64],
|
||||
depths: FloatArray,
|
||||
projected_source_indices: npt.NDArray[np.int64],
|
||||
points_map: FloatArray,
|
||||
points_lidar: FloatArray,
|
||||
) -> tuple[ObjectFusion, ...]:
|
||||
sampled_instances = instance_map[pixel_y, pixel_x]
|
||||
fused: list[ObjectFusion] = []
|
||||
for item in instances:
|
||||
instance_id = int(item["instance_id"])
|
||||
candidates = np.flatnonzero(sampled_instances == instance_id).astype(np.int64)
|
||||
clustered = _depth_cluster(candidates, depths)
|
||||
source_indices = projected_source_indices[clustered]
|
||||
ranges = np.linalg.norm(points_lidar[source_indices], axis=1)
|
||||
distance_p10 = None if ranges.size == 0 else float(np.percentile(ranges, 10.0))
|
||||
distance_median = None if ranges.size == 0 else float(np.median(ranges))
|
||||
center: tuple[float, float, float] | None = None
|
||||
half_size: tuple[float, float, float] | None = None
|
||||
if source_indices.size < MIN_BOX_POINTS:
|
||||
status = f"rejected-fewer-than-{MIN_BOX_POINTS}-clustered-points"
|
||||
else:
|
||||
selected = points_map[source_indices]
|
||||
lower = np.percentile(selected, 5.0, axis=0)
|
||||
upper = np.percentile(selected, 95.0, axis=0)
|
||||
spans = upper - lower
|
||||
if not np.isfinite(spans).all() or np.any(spans > MAX_BOX_SPAN_M):
|
||||
status = "rejected-implausible-span"
|
||||
else:
|
||||
sizes = np.maximum(spans, 0.15)
|
||||
center = tuple(float(value) for value in ((lower + upper) * 0.5))
|
||||
half_size = tuple(float(value) for value in (sizes * 0.5))
|
||||
status = "accepted-diagnostic-axis-aligned"
|
||||
fused.append(
|
||||
ObjectFusion(
|
||||
instance_id=instance_id,
|
||||
label=str(item["label"]),
|
||||
score=float(item["score"]),
|
||||
candidate_points=int(candidates.size),
|
||||
clustered_points=int(clustered.size),
|
||||
distance_p10_m=distance_p10,
|
||||
distance_median_m=distance_median,
|
||||
box_center_map=center,
|
||||
box_half_size_map=half_size,
|
||||
box_status=status,
|
||||
source_indices=source_indices,
|
||||
)
|
||||
)
|
||||
return tuple(fused)
|
||||
|
||||
|
||||
def _fusion_overlay(
|
||||
semantic_overlay: RgbImage,
|
||||
projected_pixels: FloatArray,
|
||||
semantic_colors: RgbImage,
|
||||
objects: tuple[ObjectFusion, ...],
|
||||
instances: list[dict[str, Any]],
|
||||
) -> RgbImage:
|
||||
canvas = Image.fromarray(semantic_overlay).convert("RGBA")
|
||||
draw = ImageDraw.Draw(canvas, "RGBA")
|
||||
for (u, v), color in zip(projected_pixels, semantic_colors, strict=True):
|
||||
red, green, blue = (int(value) for value in color)
|
||||
draw.ellipse((u - 1.5, v - 1.5, u + 1.5, v + 1.5), fill=(red, green, blue, 210))
|
||||
by_id = {item.instance_id: item for item in objects}
|
||||
for item in instances:
|
||||
fused = by_id[int(item["instance_id"])]
|
||||
x1, y1, x2, y2 = (float(value) for value in item["box_xyxy"])
|
||||
color = _palette(500 + fused.instance_id)
|
||||
accepted = fused.box_center_map is not None
|
||||
alpha = 255 if accepted else 135
|
||||
draw.rectangle((x1, y1, x2, y2), outline=(*color, alpha), width=2)
|
||||
distance = (
|
||||
"no LiDAR"
|
||||
if fused.distance_median_m is None
|
||||
else f"{fused.distance_median_m:.1f}m/{fused.clustered_points}pts"
|
||||
)
|
||||
draw.text(
|
||||
(x1 + 2, max(0.0, y1 - 12)),
|
||||
f"{fused.label} {distance}",
|
||||
fill=(255, 255, 255, alpha),
|
||||
stroke_width=2,
|
||||
stroke_fill=(0, 0, 0, alpha),
|
||||
)
|
||||
return np.asarray(canvas.convert("RGB"), dtype=np.uint8)
|
||||
|
||||
|
||||
def _object_document(item: ObjectFusion) -> dict[str, Any]:
|
||||
return {
|
||||
"instance_id": item.instance_id,
|
||||
"label": item.label,
|
||||
"score": round(item.score, 9),
|
||||
"candidate_projected_points": item.candidate_points,
|
||||
"clustered_points": item.clustered_points,
|
||||
"distance_p10_m": item.distance_p10_m,
|
||||
"distance_median_m": item.distance_median_m,
|
||||
"box_status": item.box_status,
|
||||
"box_center_map": item.box_center_map,
|
||||
"box_half_size_map": item.box_half_size_map,
|
||||
}
|
||||
|
||||
|
||||
def _write_rerun(path: Path, experiment_id: str, frames: tuple[FusedFrame, ...]) -> None:
|
||||
recording = rr.RecordingStream("nodedc_k1_calibrated_segmentation", recording_id=experiment_id)
|
||||
recording.set_sinks(rr.FileSink(path, write_footer=True))
|
||||
try:
|
||||
recording.log(
|
||||
"contract",
|
||||
rr.TextDocument(
|
||||
"Recorded-only diagnostic: exact image masks sampled at factory-calibrated "
|
||||
"K1 LiDAR projections; 3D boxes require clustered LiDAR support."
|
||||
),
|
||||
static=True,
|
||||
)
|
||||
for frame in frames:
|
||||
recording.set_time(
|
||||
"session_time",
|
||||
duration=np.timedelta64(round(frame.session_time_seconds * 1_000_000_000), "ns"),
|
||||
)
|
||||
recording.log("camera/raw", rr.Image(frame.image_rgb))
|
||||
recording.log("camera/semantic", rr.Image(frame.semantic_overlay_rgb))
|
||||
recording.log("camera/fusion", rr.Image(frame.fusion_overlay_rgb))
|
||||
recording.log(
|
||||
"camera/projected_lidar_semantic",
|
||||
rr.Points2D(
|
||||
frame.projected_pixels.astype(np.float32),
|
||||
colors=frame.semantic_colors,
|
||||
radii=rr.Radius.ui_points(2.0),
|
||||
),
|
||||
)
|
||||
recording.log(
|
||||
"world/semantic_points",
|
||||
rr.Points3D(
|
||||
frame.points_map[frame.projected_source_indices].astype(np.float32),
|
||||
colors=frame.semantic_colors,
|
||||
radii=rr.Radius.ui_points(2.0),
|
||||
),
|
||||
)
|
||||
boxed = [item for item in frame.objects if item.box_center_map is not None]
|
||||
if boxed:
|
||||
recording.log(
|
||||
"world/boxes3d",
|
||||
rr.Boxes3D(
|
||||
centers=[item.box_center_map for item in boxed],
|
||||
half_sizes=[item.box_half_size_map for item in boxed],
|
||||
colors=[(*_palette(500 + item.instance_id), 96) for item in boxed],
|
||||
fill_mode=FillMode.Solid,
|
||||
labels=[
|
||||
f"{item.label} · {item.distance_median_m:.1f} m · "
|
||||
f"{item.clustered_points} pts"
|
||||
for item in boxed
|
||||
],
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
recording.flush(timeout_sec=30.0)
|
||||
recording.disconnect()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
session = args.session.resolve(strict=True)
|
||||
calibration_root = args.calibration.resolve(strict=True)
|
||||
calibrated_manifest = _read_json(args.calibrated_manifest.resolve(strict=True))
|
||||
segmentation_root = args.segmentation.resolve(strict=True)
|
||||
segmentation_manifest = _read_json(segmentation_root / "manifest.redacted.json")
|
||||
ffmpeg = args.ffmpeg.resolve(strict=True)
|
||||
|
||||
models = segmentation_manifest.get("models")
|
||||
acceptance = segmentation_manifest.get("acceptance")
|
||||
if not isinstance(models, dict) or not isinstance(acceptance, dict):
|
||||
raise RuntimeError("segmentation manifest is incomplete")
|
||||
instance_model = models.get("instance")
|
||||
semantic_model = models.get("semantic")
|
||||
if (
|
||||
not isinstance(instance_model, dict)
|
||||
or instance_model.get("id") != ACCEPTED_INSTANCE_MODEL
|
||||
or not isinstance(semantic_model, dict)
|
||||
or semantic_model.get("id") != ACCEPTED_SEMANTIC_MODEL
|
||||
or semantic_model.get("checkpoint_load") != "exact-no-missing-unexpected-or-mismatched-keys"
|
||||
or acceptance.get("recorded_segmentation_artifact") != "generated"
|
||||
):
|
||||
raise RuntimeError("segmentation result is not an admitted strict-load experiment")
|
||||
|
||||
calibration, calibration_identity = _load_calibration_snapshot(calibration_root)
|
||||
calibrated_input = calibrated_manifest.get("input")
|
||||
if (
|
||||
not isinstance(calibrated_input, dict)
|
||||
or calibrated_input.get("session_id") != session.name
|
||||
or calibrated_input.get("calibration_content_identity_sha256") != calibration_identity
|
||||
):
|
||||
raise RuntimeError("calibrated experiment does not bind this session/calibration")
|
||||
source_id = str(calibrated_input["source_id"])
|
||||
offsets = tuple(float(value) for value in calibrated_input["video_offsets_seconds"])
|
||||
profile = Kb4ProjectionProfile.from_factory_calibration(calibration, source_id)
|
||||
|
||||
capture_root = session / "captures" / "mqtt_live"
|
||||
origin = read_capture_clock_origin(capture_root / "mqtt.timeline.origin.json")
|
||||
epoch_root = session / "media" / source_id / "epoch-1"
|
||||
epoch = inspect_recorded_media_epoch(
|
||||
epoch_root,
|
||||
expected_source_name=source_id,
|
||||
origin_epoch_ns=origin.started_at_epoch_ns,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
)
|
||||
anchors = _select_camera_anchors(
|
||||
epoch_root / "index.jsonl",
|
||||
expected_count=len(epoch.segments),
|
||||
offsets_seconds=offsets,
|
||||
timeline_start_seconds=epoch.timeline_start_seconds,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
)
|
||||
images = _extract_camera_frames(
|
||||
ffmpeg,
|
||||
init_path=epoch.init_path,
|
||||
segment_paths=tuple(item.path for item in epoch.segments),
|
||||
frame_sequences=tuple(item.sequence for item in anchors),
|
||||
width=profile.width,
|
||||
height=profile.height,
|
||||
)
|
||||
lidar_samples = _select_lidar_samples(
|
||||
capture_root / "mqtt.raw.k1mqtt",
|
||||
anchors,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
temporal_offset_seconds=float(calibrated_input["temporal_offset_seconds"]),
|
||||
)
|
||||
|
||||
segmentation_inputs = {
|
||||
str(item["name"]): str(item["sha256"])
|
||||
for item in segmentation_manifest["input"]
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
segmentation_frames = segmentation_manifest["frames"]
|
||||
fused_frames: list[FusedFrame] = []
|
||||
for image, anchor, lidar_sample in zip(images, anchors, lidar_samples, strict=True):
|
||||
stem = (
|
||||
f"camera-{anchor.sequence:06d}-"
|
||||
f"session-{round(anchor.session_time_seconds * 1000):09d}ms"
|
||||
)
|
||||
input_name = f"{stem}.png"
|
||||
input_artifact = args.calibrated_manifest.parent / input_name
|
||||
if segmentation_inputs.get(input_name) != _sha256(input_artifact):
|
||||
raise RuntimeError(f"segmentation input identity changed for {input_name}")
|
||||
if _sha256(input_artifact) != hashlib.sha256(image.tobytes()).hexdigest():
|
||||
decoded = np.asarray(Image.open(input_artifact).convert("RGB"), dtype=np.uint8)
|
||||
if not np.array_equal(decoded, image):
|
||||
raise RuntimeError(f"decoded camera pixels changed for {input_name}")
|
||||
|
||||
semantic_map = np.asarray(Image.open(segmentation_root / f"{stem}.semantic.png"))
|
||||
instance_map = np.asarray(Image.open(segmentation_root / f"{stem}.instances.png"))
|
||||
semantic_overlay = np.asarray(
|
||||
Image.open(segmentation_root / f"{stem}.semantic-overlay.png").convert("RGB")
|
||||
)
|
||||
if semantic_map.shape != image.shape[:2] or instance_map.shape != image.shape[:2]:
|
||||
raise RuntimeError("segmentation maps do not match the camera epoch")
|
||||
|
||||
points_map = np.asarray(
|
||||
[
|
||||
point.scaled_xyz(lidar_sample.point_frame.header.scaler)
|
||||
for point in lidar_sample.point_frame.points
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
points_lidar = map_points_to_lidar(
|
||||
points_map,
|
||||
position_map_xyz=lidar_sample.pose_frame.position_xyz,
|
||||
orientation_map_from_lidar_xyzw=lidar_sample.pose_frame.orientation_xyzw,
|
||||
)
|
||||
projection = project_map_points_kb4(
|
||||
points_map,
|
||||
position_map_xyz=lidar_sample.pose_frame.position_xyz,
|
||||
orientation_map_from_lidar_xyzw=lidar_sample.pose_frame.orientation_xyzw,
|
||||
profile=profile,
|
||||
)
|
||||
pixel_x = np.clip(
|
||||
np.rint(projection.pixels_xy[:, 0]).astype(np.int64), 0, profile.width - 1
|
||||
)
|
||||
pixel_y = np.clip(
|
||||
np.rint(projection.pixels_xy[:, 1]).astype(np.int64), 0, profile.height - 1
|
||||
)
|
||||
semantic_ids = semantic_map[pixel_y, pixel_x].astype(np.int64)
|
||||
semantic_colors = np.asarray(
|
||||
[_palette(int(value)) for value in semantic_ids], dtype=np.uint8
|
||||
)
|
||||
frame_document = segmentation_frames[stem]
|
||||
instances = frame_document["instance"]["instances"]
|
||||
objects = _object_fusions(
|
||||
instance_map=instance_map.astype(np.uint16),
|
||||
instances=instances,
|
||||
pixel_x=pixel_x,
|
||||
pixel_y=pixel_y,
|
||||
depths=projection.depths_m,
|
||||
projected_source_indices=projection.source_indices,
|
||||
points_map=points_map,
|
||||
points_lidar=points_lidar,
|
||||
)
|
||||
fusion_overlay = _fusion_overlay(
|
||||
semantic_overlay,
|
||||
projection.pixels_xy,
|
||||
semantic_colors,
|
||||
objects,
|
||||
instances,
|
||||
)
|
||||
fused_frames.append(
|
||||
FusedFrame(
|
||||
stem=stem,
|
||||
session_time_seconds=anchor.session_time_seconds,
|
||||
image_rgb=image,
|
||||
semantic_overlay_rgb=semantic_overlay,
|
||||
fusion_overlay_rgb=fusion_overlay,
|
||||
points_map=points_map,
|
||||
projected_source_indices=projection.source_indices,
|
||||
projected_pixels=projection.pixels_xy,
|
||||
semantic_colors=semantic_colors,
|
||||
objects=objects,
|
||||
)
|
||||
)
|
||||
|
||||
created_at = datetime.now(UTC)
|
||||
experiment_id = (
|
||||
f"{created_at.strftime('%Y%m%dT%H%M%SZ')}_k1_segmentation_fusion_{uuid4().hex[:12]}"
|
||||
)
|
||||
private_root = args.output_root.resolve() / "private" / "perception-experiments"
|
||||
private_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
output = private_root / experiment_id
|
||||
staging = private_root / f".{experiment_id}.incomplete"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
try:
|
||||
for frame in fused_frames:
|
||||
Image.fromarray(frame.fusion_overlay_rgb).save(staging / f"{frame.stem}.fusion.png")
|
||||
mosaic_rows = [
|
||||
np.concatenate(
|
||||
(frame.image_rgb, frame.semantic_overlay_rgb, frame.fusion_overlay_rgb),
|
||||
axis=1,
|
||||
)
|
||||
for frame in fused_frames
|
||||
]
|
||||
Image.fromarray(np.concatenate(mosaic_rows, axis=0)).save(staging / "fusion-mosaic.png")
|
||||
_write_rerun(staging / "fusion.rrd", experiment_id, tuple(fused_frames))
|
||||
identity = {
|
||||
"calibrated_generation_sha256": calibrated_manifest["generation_sha256"],
|
||||
"segmentation_generation_sha256": segmentation_manifest["generation_sha256"],
|
||||
"calibration_content_identity_sha256": calibration_identity,
|
||||
"session_id": session.name,
|
||||
"source_id": source_id,
|
||||
}
|
||||
outputs = sorted(staging.iterdir())
|
||||
manifest = {
|
||||
"schema_version": SCHEMA,
|
||||
"experiment_id": experiment_id,
|
||||
"created_at_utc": created_at.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
"generation_sha256": hashlib.sha256(
|
||||
json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest(),
|
||||
"input": identity,
|
||||
"fusion": {
|
||||
"pixel_sampling": "nearest-calibrated-projection",
|
||||
"occlusion_policy": "largest-depth-contiguous-cluster-nearest-tiebreak",
|
||||
"distance": "LiDAR-origin Euclidean p10 and median",
|
||||
"box": {
|
||||
"frame": "map",
|
||||
"orientation": "axis-aligned-diagnostic",
|
||||
"minimum_clustered_points": MIN_BOX_POINTS,
|
||||
"robust_bounds": "p05-p95",
|
||||
"maximum_span_m": MAX_BOX_SPAN_M,
|
||||
},
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"stem": frame.stem,
|
||||
"session_time_seconds": frame.session_time_seconds,
|
||||
"projected_points": int(frame.projected_source_indices.size),
|
||||
"semantic_class_ids": sorted(
|
||||
{
|
||||
int(value)
|
||||
for value in np.asarray(
|
||||
Image.open(segmentation_root / f"{frame.stem}.semantic.png")
|
||||
).reshape(-1)
|
||||
}
|
||||
),
|
||||
"objects": [_object_document(item) for item in frame.objects],
|
||||
"accepted_boxes": sum(
|
||||
item.box_center_map is not None for item in frame.objects
|
||||
),
|
||||
}
|
||||
for frame in fused_frames
|
||||
],
|
||||
"acceptance": {
|
||||
"recorded_mask_to_lidar_fusion": "generated",
|
||||
"distance": "diagnostic-not-ground-truthed",
|
||||
"boxes3d": "diagnostic-not-tracked-or-ground-truthed",
|
||||
"live": "not-tested",
|
||||
"safety": "not-accepted",
|
||||
},
|
||||
"outputs": [
|
||||
{"name": path.name, "bytes": path.stat().st_size, "sha256": _sha256(path)}
|
||||
for path in outputs
|
||||
],
|
||||
}
|
||||
(staging / "manifest.redacted.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, output)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
print(json.dumps({"experiment_id": experiment_id, "output": str(output)}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,498 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fuse one admitted full camera-perception epoch into compact 3D observations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from fuse_calibrated_segmentation import _object_fusions, _palette
|
||||
from PIL import Image
|
||||
|
||||
from k1link.compute import validate_recorded_perception_epoch_result
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_overlay import (
|
||||
MAX_SYNC_DELTA_SECONDS,
|
||||
_load_calibration_snapshot,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
Kb4ProjectionProfile,
|
||||
map_points_to_lidar,
|
||||
project_map_points_kb4,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
LioPointCloudFrame,
|
||||
LioPoseFrame,
|
||||
decode_lio_pcl,
|
||||
decode_lio_pose,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||
|
||||
SCHEMA = "missioncore.recorded-calibrated-fusion/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.recorded-calibrated-fusion-identity/v1"
|
||||
MAX_MASK_MEMBER_BYTES = 4 * 1024 * 1024
|
||||
MAX_INDEX_LINE_BYTES = 64 * 1024
|
||||
|
||||
FloatArray = npt.NDArray[np.float64]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CameraAnchor:
|
||||
sequence: int
|
||||
host_session_seconds: float
|
||||
video_session_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LidarSample:
|
||||
point_session_seconds: float
|
||||
pose_session_seconds: float
|
||||
point_frame: LioPointCloudFrame
|
||||
pose_frame: LioPoseFrame
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job", type=Path, required=True)
|
||||
parser.add_argument("--result", type=Path, required=True)
|
||||
parser.add_argument("--session", 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 _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_frame_metadata(path: Path, expected_count: int) -> list[dict[str, Any]]:
|
||||
frames: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for expected_index, line in enumerate(stream):
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict) or value.get("frame_index") != expected_index:
|
||||
raise RuntimeError("perception frame metadata changed")
|
||||
frames.append(value)
|
||||
if len(frames) != expected_count:
|
||||
raise RuntimeError("perception frame metadata is incomplete")
|
||||
return frames
|
||||
|
||||
|
||||
def _extract_masks(archive: Path, destination: Path, frame_count: int) -> None:
|
||||
expected = {
|
||||
f"{kind}/frame-{sequence:06d}.png"
|
||||
for kind in ("instance-masks", "semantic-masks")
|
||||
for sequence in range(1, frame_count + 1)
|
||||
}
|
||||
with tarfile.open(archive, mode="r:gz") as source:
|
||||
members = [member for member in source.getmembers() if member.isfile()]
|
||||
found = {member.name.replace("\\", "/").removeprefix("./") for member in members}
|
||||
if found != expected or len(members) != len(expected):
|
||||
raise RuntimeError("panoptic mask archive contents changed")
|
||||
for member in members:
|
||||
name = member.name.replace("\\", "/").removeprefix("./")
|
||||
if (
|
||||
member.issym()
|
||||
or member.islnk()
|
||||
or member.size < 1
|
||||
or member.size > MAX_MASK_MEMBER_BYTES
|
||||
):
|
||||
raise RuntimeError("panoptic mask archive member is unsafe")
|
||||
target = destination.joinpath(*name.split("/"))
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
payload = source.extractfile(member)
|
||||
if payload is None:
|
||||
raise RuntimeError("panoptic mask archive member is unavailable")
|
||||
with target.open("xb") as sink:
|
||||
shutil.copyfileobj(payload, sink, length=1024 * 1024)
|
||||
if target.stat().st_size != member.size:
|
||||
raise RuntimeError("panoptic mask archive member was truncated")
|
||||
|
||||
|
||||
def _camera_anchors(
|
||||
index_path: Path,
|
||||
frame_metadata: list[dict[str, Any]],
|
||||
*,
|
||||
origin_monotonic_ns: int,
|
||||
) -> list[CameraAnchor]:
|
||||
anchors: list[CameraAnchor] = []
|
||||
with index_path.open("rb") as stream:
|
||||
for sequence, frame in enumerate(frame_metadata, start=1):
|
||||
line = stream.readline(MAX_INDEX_LINE_BYTES + 1)
|
||||
if not line or len(line) > MAX_INDEX_LINE_BYTES or not line.endswith(b"\n"):
|
||||
raise RuntimeError("camera index is incomplete")
|
||||
value = json.loads(line)
|
||||
monotonic_ns = value.get("host_monotonic_ns") if isinstance(value, dict) else None
|
||||
video_time = frame.get("session_seconds")
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("sequence") != sequence
|
||||
or not isinstance(monotonic_ns, int)
|
||||
or monotonic_ns < origin_monotonic_ns
|
||||
or not isinstance(video_time, (int, float))
|
||||
or not math.isfinite(float(video_time))
|
||||
):
|
||||
raise RuntimeError("camera anchor is invalid")
|
||||
anchors.append(
|
||||
CameraAnchor(
|
||||
sequence=sequence,
|
||||
host_session_seconds=(monotonic_ns - origin_monotonic_ns) / 1e9,
|
||||
video_session_seconds=float(video_time),
|
||||
)
|
||||
)
|
||||
if stream.read(1):
|
||||
raise RuntimeError("camera index has undeclared rows")
|
||||
return anchors
|
||||
|
||||
|
||||
def _lidar_samples(
|
||||
raw_path: Path,
|
||||
anchors: list[CameraAnchor],
|
||||
*,
|
||||
origin_monotonic_ns: int,
|
||||
) -> Iterator[LidarSample | None]:
|
||||
messages = iter(iter_replay_messages(raw_path))
|
||||
pending = next(messages, None)
|
||||
points: deque[tuple[float, LioPointCloudFrame]] = deque()
|
||||
poses: deque[tuple[float, LioPoseFrame]] = deque()
|
||||
|
||||
def message_time(message: Any) -> float:
|
||||
monotonic_ns = message.received_monotonic_ns
|
||||
if not isinstance(monotonic_ns, int) or monotonic_ns < origin_monotonic_ns:
|
||||
raise RuntimeError("MQTT replay message has no compatible monotonic time")
|
||||
return (monotonic_ns - origin_monotonic_ns) / 1e9
|
||||
|
||||
for anchor in anchors:
|
||||
target = anchor.host_session_seconds
|
||||
while pending is not None and message_time(pending) <= target + 0.5:
|
||||
timestamp = message_time(pending)
|
||||
if timestamp >= target - 0.5:
|
||||
if pending.topic.endswith("/lio_pcl"):
|
||||
points.append((timestamp, decode_lio_pcl(pending.payload)))
|
||||
elif pending.topic.endswith("/lio_pose"):
|
||||
poses.append((timestamp, decode_lio_pose(pending.payload)))
|
||||
pending = next(messages, None)
|
||||
while points and points[0][0] < target - 0.5:
|
||||
points.popleft()
|
||||
while poses and poses[0][0] < target - 0.5:
|
||||
poses.popleft()
|
||||
point_candidates = [
|
||||
item for item in points if abs(item[0] - target) <= MAX_SYNC_DELTA_SECONDS
|
||||
]
|
||||
if not point_candidates:
|
||||
yield None
|
||||
continue
|
||||
point_time, point_frame = min(point_candidates, key=lambda item: abs(item[0] - target))
|
||||
pose_candidates = [
|
||||
item for item in poses if abs(item[0] - point_time) <= MAX_SYNC_DELTA_SECONDS
|
||||
]
|
||||
if not pose_candidates:
|
||||
yield None
|
||||
continue
|
||||
pose_time, pose_frame = min(pose_candidates, key=lambda item: abs(item[0] - point_time))
|
||||
yield LidarSample(point_time, pose_time, point_frame, pose_frame)
|
||||
|
||||
|
||||
def _empty_points() -> npt.NDArray[np.float32]:
|
||||
return np.empty((0, 3), dtype=np.float32)
|
||||
|
||||
|
||||
def _empty_colors(channels: int) -> npt.NDArray[np.uint8]:
|
||||
return np.empty((0, channels), dtype=np.uint8)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
started = time.perf_counter()
|
||||
result = validate_recorded_perception_epoch_result(args.job, args.result)
|
||||
session = args.session.resolve(strict=True)
|
||||
if session.name != result.job.session_id:
|
||||
raise RuntimeError("fusion session does not match the perception result")
|
||||
calibration, calibration_sha256 = _load_calibration_snapshot(
|
||||
args.calibration.resolve(strict=True)
|
||||
)
|
||||
if calibration_sha256 != result.calibration_sha256:
|
||||
raise RuntimeError("fusion calibration generation does not match perception")
|
||||
profile = Kb4ProjectionProfile.from_factory_calibration(calibration, result.job.source_id)
|
||||
frame_metadata = _read_frame_metadata(
|
||||
result.artifact("panoptic-frame-metadata").path,
|
||||
result.job.segment_count,
|
||||
)
|
||||
capture_root = session / "captures" / "mqtt_live"
|
||||
origin = read_capture_clock_origin(capture_root / "mqtt.timeline.origin.json")
|
||||
epoch_root = (
|
||||
result.job.job_root
|
||||
/ "input"
|
||||
/ "camera"
|
||||
/ result.job.source_id
|
||||
/ f"epoch-{result.job.codec_epoch}"
|
||||
)
|
||||
anchors = _camera_anchors(
|
||||
epoch_root / "index.jsonl",
|
||||
frame_metadata,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
)
|
||||
identity = {
|
||||
"schema_version": IDENTITY_SCHEMA,
|
||||
"job_id": result.job.job_id,
|
||||
"input_sha256": result.job.input_sha256,
|
||||
"perception_result_id": result.result_id,
|
||||
"calibration_sha256": calibration_sha256,
|
||||
"camera_slot": result.calibration_slot,
|
||||
"configuration": {
|
||||
"pipeline": "factory-kb4-mask-to-lidar/v1",
|
||||
"temporal_binding": "nearest-host-arrival-best-effort",
|
||||
"maximum_sync_delta_seconds": MAX_SYNC_DELTA_SECONDS,
|
||||
"semantic_sampling": "nearest-projected-pixel",
|
||||
"box_policy": "depth-clustered-map-axis-aligned-p05-p95-diagnostic",
|
||||
},
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
fusion_id = f"fusion-{identity_sha256}"
|
||||
parent = args.output_root.resolve() / result.job.job_id
|
||||
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
output = parent / fusion_id
|
||||
if output.exists():
|
||||
print(json.dumps({"fusion_id": fusion_id, "output": str(output), "reused": True}))
|
||||
return 0
|
||||
staging = parent / f".{fusion_id}.{os.getpid()}.incomplete"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
masks_root = staging / "masks"
|
||||
masks_root.mkdir(mode=0o700)
|
||||
try:
|
||||
_extract_masks(
|
||||
result.artifact("panoptic-mask-archive").path,
|
||||
masks_root,
|
||||
result.job.segment_count,
|
||||
)
|
||||
frame_times_ns: list[int] = []
|
||||
point_offsets = [0]
|
||||
point_arrays: list[npt.NDArray[np.float32]] = []
|
||||
point_color_arrays: list[npt.NDArray[np.uint8]] = []
|
||||
box_offsets = [0]
|
||||
box_centers: list[tuple[float, float, float]] = []
|
||||
box_half_sizes: list[tuple[float, float, float]] = []
|
||||
box_colors: list[tuple[int, int, int, int]] = []
|
||||
box_labels: list[str] = []
|
||||
frame_reports: list[dict[str, Any]] = []
|
||||
compatible_frames = 0
|
||||
accepted_boxes = 0
|
||||
for index, (anchor, sample) in enumerate(
|
||||
zip(
|
||||
anchors,
|
||||
_lidar_samples(
|
||||
capture_root / "mqtt.raw.k1mqtt",
|
||||
anchors,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
),
|
||||
strict=True,
|
||||
)
|
||||
):
|
||||
frame_times_ns.append(round(anchor.video_session_seconds * 1e9))
|
||||
if sample is None:
|
||||
point_arrays.append(_empty_points())
|
||||
point_color_arrays.append(_empty_colors(3))
|
||||
point_offsets.append(point_offsets[-1])
|
||||
box_offsets.append(box_offsets[-1])
|
||||
frame_reports.append(
|
||||
{"frame_index": index, "state": "depth-unavailable", "points": 0, "boxes": 0}
|
||||
)
|
||||
continue
|
||||
compatible_frames += 1
|
||||
with Image.open(
|
||||
masks_root / "semantic-masks" / f"frame-{index + 1:06d}.png"
|
||||
) as semantic_image:
|
||||
semantic_map = np.array(semantic_image, dtype=np.uint8, copy=True)
|
||||
with Image.open(
|
||||
masks_root / "instance-masks" / f"frame-{index + 1:06d}.png"
|
||||
) as instance_image:
|
||||
instance_map = np.array(instance_image, dtype=np.uint16, copy=True)
|
||||
if semantic_map.shape != (profile.height, profile.width) or instance_map.shape != (
|
||||
profile.height,
|
||||
profile.width,
|
||||
):
|
||||
raise RuntimeError("panoptic mask dimensions changed")
|
||||
points_map = np.asarray(
|
||||
[
|
||||
point.scaled_xyz(sample.point_frame.header.scaler)
|
||||
for point in sample.point_frame.points
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
points_lidar = map_points_to_lidar(
|
||||
points_map,
|
||||
position_map_xyz=sample.pose_frame.position_xyz,
|
||||
orientation_map_from_lidar_xyzw=sample.pose_frame.orientation_xyzw,
|
||||
)
|
||||
projection = project_map_points_kb4(
|
||||
points_map,
|
||||
position_map_xyz=sample.pose_frame.position_xyz,
|
||||
orientation_map_from_lidar_xyzw=sample.pose_frame.orientation_xyzw,
|
||||
profile=profile,
|
||||
)
|
||||
pixel_x = np.clip(
|
||||
np.rint(projection.pixels_xy[:, 0]).astype(np.int64),
|
||||
0,
|
||||
profile.width - 1,
|
||||
)
|
||||
pixel_y = np.clip(
|
||||
np.rint(projection.pixels_xy[:, 1]).astype(np.int64),
|
||||
0,
|
||||
profile.height - 1,
|
||||
)
|
||||
semantic_ids = semantic_map[pixel_y, pixel_x].astype(np.int64)
|
||||
colors = np.asarray([_palette(int(value)) for value in semantic_ids], dtype=np.uint8)
|
||||
projected = points_map[projection.source_indices].astype(np.float32)
|
||||
point_arrays.append(projected)
|
||||
point_color_arrays.append(colors)
|
||||
point_offsets.append(point_offsets[-1] + projected.shape[0])
|
||||
instances = frame_metadata[index]["instances"]
|
||||
objects = _object_fusions(
|
||||
instance_map=instance_map,
|
||||
instances=instances,
|
||||
pixel_x=pixel_x,
|
||||
pixel_y=pixel_y,
|
||||
depths=projection.depths_m,
|
||||
projected_source_indices=projection.source_indices,
|
||||
points_map=points_map,
|
||||
points_lidar=points_lidar,
|
||||
)
|
||||
frame_box_count = 0
|
||||
for item in objects:
|
||||
if item.box_center_map is None or item.box_half_size_map is None:
|
||||
continue
|
||||
frame_box_count += 1
|
||||
accepted_boxes += 1
|
||||
box_centers.append(item.box_center_map)
|
||||
box_half_sizes.append(item.box_half_size_map)
|
||||
box_colors.append((*_palette(500 + item.instance_id), 96))
|
||||
box_labels.append(
|
||||
f"{item.label} · {item.distance_median_m:.1f} m · {item.clustered_points} pts"
|
||||
)
|
||||
box_offsets.append(box_offsets[-1] + frame_box_count)
|
||||
frame_reports.append(
|
||||
{
|
||||
"frame_index": index,
|
||||
"state": "fused",
|
||||
"points": int(projected.shape[0]),
|
||||
"boxes": frame_box_count,
|
||||
"lidar_camera_delta_ms": round(
|
||||
(sample.point_session_seconds - anchor.host_session_seconds) * 1000,
|
||||
6,
|
||||
),
|
||||
"pose_point_delta_ms": round(
|
||||
(sample.pose_session_seconds - sample.point_session_seconds) * 1000,
|
||||
6,
|
||||
),
|
||||
}
|
||||
)
|
||||
if (index + 1) % 250 == 0 or index + 1 == result.job.segment_count:
|
||||
print(
|
||||
json.dumps(
|
||||
{"phase": "fusion", "frames": index + 1, "total": result.job.segment_count}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
arrays_path = staging / "fusion.npz"
|
||||
np.savez_compressed(
|
||||
arrays_path,
|
||||
frame_times_ns=np.asarray(frame_times_ns, dtype=np.int64),
|
||||
point_offsets=np.asarray(point_offsets, dtype=np.int64),
|
||||
points=(np.concatenate(point_arrays) if point_arrays else _empty_points()),
|
||||
point_colors=(
|
||||
np.concatenate(point_color_arrays) if point_color_arrays else _empty_colors(3)
|
||||
),
|
||||
box_offsets=np.asarray(box_offsets, dtype=np.int64),
|
||||
box_centers=np.asarray(box_centers, dtype=np.float32).reshape((-1, 3)),
|
||||
box_half_sizes=np.asarray(box_half_sizes, dtype=np.float32).reshape((-1, 3)),
|
||||
box_colors=np.asarray(box_colors, dtype=np.uint8).reshape((-1, 4)),
|
||||
)
|
||||
labels_path = staging / "box-labels.json"
|
||||
labels_path.write_text(
|
||||
json.dumps(box_labels, ensure_ascii=False, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
frames_path = staging / "fusion-frames.jsonl"
|
||||
frames_path.write_text(
|
||||
"".join(json.dumps(item, sort_keys=True) + "\n" for item in frame_reports),
|
||||
encoding="utf-8",
|
||||
)
|
||||
shutil.rmtree(masks_root)
|
||||
created_at = datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
artifacts = [
|
||||
{
|
||||
"name": path.name,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
for path in (arrays_path, labels_path, frames_path)
|
||||
]
|
||||
manifest = {
|
||||
"schema_version": SCHEMA,
|
||||
"fusion_id": fusion_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": created_at,
|
||||
"session_id": result.job.session_id,
|
||||
"source_id": result.job.source_id,
|
||||
"frame_count": result.job.segment_count,
|
||||
"timeline_start_seconds": result.job.timeline_start_seconds,
|
||||
"timeline_end_seconds": result.job.timeline_end_seconds,
|
||||
"metrics": {
|
||||
"frames_fused": compatible_frames,
|
||||
"frames_depth_unavailable": result.job.segment_count - compatible_frames,
|
||||
"semantic_points": point_offsets[-1],
|
||||
"accepted_diagnostic_boxes": accepted_boxes,
|
||||
"wall_seconds": round(time.perf_counter() - started, 6),
|
||||
},
|
||||
"acceptance": {
|
||||
"geometry": "factory-calibrated-kb4",
|
||||
"timing": "host-arrival-best-effort-not-ground-truthed",
|
||||
"distance": "diagnostic-not-ground-truthed",
|
||||
"boxes3d": "support-gated-diagnostic-not-tracked",
|
||||
"safety": "not-accepted",
|
||||
},
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
(staging / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, output)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
print(json.dumps({"fusion_id": fusion_id, "output": str(output), "reused": False}))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build a full-camera-epoch LAB E10 LiDAR/pose replay pack from raw evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from e10_fusion_runtime import PACK_SCHEMA, LidarReplayPack, canonical_json, sha256
|
||||
from fuse_e6_tracking_lidar import _camera_anchors, _lidar_samples, _read_profile
|
||||
|
||||
from k1link.compute import validate_tracked_fusion_qualification_result
|
||||
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
|
||||
|
||||
|
||||
def arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job", type=Path, required=True)
|
||||
parser.add_argument("--tracking", type=Path, required=True)
|
||||
parser.add_argument("--semantic", type=Path, required=True)
|
||||
parser.add_argument("--e6-result", type=Path, required=True)
|
||||
parser.add_argument("--e6-profile", type=Path, required=True)
|
||||
parser.add_argument("--session", 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_e6_identity(result_root: Path) -> dict[str, Any]:
|
||||
document = json.loads(
|
||||
(result_root.resolve(strict=True) / "result.json").read_text(encoding="utf-8")
|
||||
)
|
||||
identity = document.get("identity") if isinstance(document, dict) else None
|
||||
if not isinstance(identity, dict):
|
||||
raise RuntimeError("accepted LAB E6 identity is unavailable")
|
||||
return identity
|
||||
|
||||
|
||||
def _read_full_timeline(path: Path, expected_count: int) -> list[dict[str, Any]]:
|
||||
frames: list[dict[str, Any]] = []
|
||||
previous_seconds = -1.0
|
||||
with path.resolve(strict=True).open(encoding="utf-8") as stream:
|
||||
for frame_index, line in enumerate(stream):
|
||||
value = json.loads(line)
|
||||
session_seconds = value.get("session_seconds") if isinstance(value, dict) else None
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("frame_index") != frame_index
|
||||
or value.get("sequence") != frame_index + 1
|
||||
or not isinstance(session_seconds, (int, float))
|
||||
or isinstance(session_seconds, bool)
|
||||
or not float(session_seconds) > previous_seconds
|
||||
):
|
||||
raise RuntimeError("full camera timeline is invalid")
|
||||
frames.append(
|
||||
{
|
||||
"frame_index": frame_index,
|
||||
"source_frame_index": frame_index,
|
||||
"session_seconds": float(session_seconds),
|
||||
"tracks": [],
|
||||
}
|
||||
)
|
||||
previous_seconds = float(session_seconds)
|
||||
if len(frames) != expected_count:
|
||||
raise RuntimeError("full camera timeline count changed")
|
||||
return frames
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = arguments()
|
||||
upstream = validate_tracked_fusion_qualification_result(
|
||||
args.job,
|
||||
args.tracking,
|
||||
args.semantic,
|
||||
args.e6_result,
|
||||
)
|
||||
session = args.session.resolve(strict=True)
|
||||
if session.name != upstream.job.session_id:
|
||||
raise RuntimeError("LAB E10 full session differs from accepted E6")
|
||||
e6_profile, e6_profile_sha256 = _read_profile(args.e6_profile)
|
||||
e6_identity = _read_e6_identity(upstream.result_root)
|
||||
if (
|
||||
e6_identity.get("profile_sha256") != e6_profile_sha256
|
||||
or e6_identity.get("configuration") != e6_profile
|
||||
):
|
||||
raise RuntimeError("LAB E10 full pack temporal policy differs from accepted E6")
|
||||
calibration, calibration_sha256 = _load_calibration_snapshot(
|
||||
args.calibration.resolve(strict=True)
|
||||
)
|
||||
if calibration_sha256 != upstream.semantic.calibration_sha256:
|
||||
raise RuntimeError("LAB E10 full pack calibration differs from accepted E6")
|
||||
projection = Kb4ProjectionProfile.from_factory_calibration(
|
||||
calibration,
|
||||
upstream.job.source_id,
|
||||
)
|
||||
timeline_frames = _read_full_timeline(
|
||||
upstream.semantic.artifact("panoptic-frame-metadata").path,
|
||||
upstream.job.segment_count,
|
||||
)
|
||||
capture_root = session / "captures" / "mqtt_live"
|
||||
origin = read_capture_clock_origin(capture_root / "mqtt.timeline.origin.json")
|
||||
epoch_root = (
|
||||
upstream.job.job_root
|
||||
/ "input"
|
||||
/ "camera"
|
||||
/ upstream.job.source_id
|
||||
/ f"epoch-{upstream.job.codec_epoch}"
|
||||
)
|
||||
anchors = _camera_anchors(
|
||||
epoch_root / "index.jsonl",
|
||||
timeline_frames,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
)
|
||||
maximum_lidar_delta_s = float(e6_profile["temporal"]["maximum_lidar_camera_delta_ms"]) / 1000.0
|
||||
maximum_pose_delta_s = float(e6_profile["temporal"]["maximum_pose_point_delta_ms"]) / 1000.0
|
||||
samples = _lidar_samples(
|
||||
capture_root / "mqtt.raw.k1mqtt",
|
||||
anchors,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
maximum_lidar_camera_delta_s=maximum_lidar_delta_s,
|
||||
maximum_pose_point_delta_s=maximum_pose_delta_s,
|
||||
)
|
||||
|
||||
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)
|
||||
for index, (anchor, sample) in enumerate(zip(anchors, samples, strict=True)):
|
||||
if sample is None:
|
||||
offsets.append(offsets[-1])
|
||||
else:
|
||||
cloud = np.asarray(
|
||||
[
|
||||
point.scaled_xyz(sample.point_frame.header.scaler)
|
||||
for point in sample.point_frame.points
|
||||
],
|
||||
dtype=np.float32,
|
||||
).reshape((-1, 3))
|
||||
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
|
||||
if (index + 1) % 500 == 0 or index + 1 == count:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"phase": "full-lidar-pack",
|
||||
"frames": index + 1,
|
||||
"total": count,
|
||||
"lidar_frames": int(available[: index + 1].sum()),
|
||||
"points": offsets[-1],
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
source_indices = np.arange(count, dtype=np.int64)
|
||||
session_seconds = np.asarray(
|
||||
[anchor.video_session_seconds for anchor in anchors], dtype=np.float64
|
||||
)
|
||||
temporal = {
|
||||
"binding": e6_profile["temporal"]["binding"],
|
||||
"maximum_lidar_camera_delta_ms": float(
|
||||
e6_profile["temporal"]["maximum_lidar_camera_delta_ms"]
|
||||
),
|
||||
"maximum_pose_point_delta_ms": float(e6_profile["temporal"]["maximum_pose_point_delta_ms"]),
|
||||
"clock_source": "recorded-host-monotonic-arrival",
|
||||
}
|
||||
identity = {
|
||||
"schema_version": PACK_SCHEMA,
|
||||
"job_id": upstream.job.job_id,
|
||||
"input_sha256": upstream.job.input_sha256,
|
||||
"session_id": upstream.job.session_id,
|
||||
"source_id": upstream.job.source_id,
|
||||
"camera_slot": upstream.semantic.calibration_slot,
|
||||
"calibration_sha256": calibration_sha256,
|
||||
"e6_result_id": upstream.result_id,
|
||||
"e6_profile_sha256": e6_profile_sha256,
|
||||
"semantic_timeline_result_id": upstream.semantic.result_id,
|
||||
"frame_count": count,
|
||||
"source_start_frame_index": 0,
|
||||
"source_end_frame_index": count - 1,
|
||||
"timeline_start_seconds": float(session_seconds[0]),
|
||||
"timeline_end_seconds": float(session_seconds[-1]),
|
||||
"available_lidar_frames": int(available.sum()),
|
||||
"point_count": int(offsets[-1]),
|
||||
"temporal_binding": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"temporal_policy": temporal,
|
||||
"projection": {
|
||||
"model": "kb4",
|
||||
"width": projection.width,
|
||||
"height": projection.height,
|
||||
"source_coordinates": "k1-map",
|
||||
"target_camera": upstream.job.source_id,
|
||||
},
|
||||
"producer_sha256": sha256(Path(__file__).resolve(strict=True)),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||
pack_id = f"e10-lidar-pack-{identity_sha256}"
|
||||
parent = args.output_root.resolve()
|
||||
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
output = parent / pack_id
|
||||
if output.exists():
|
||||
print(json.dumps({"pack_id": pack_id, "output": str(output), "reused": True}))
|
||||
return 0
|
||||
staging = parent / f".{pack_id}.{os.getpid()}.incomplete"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
try:
|
||||
arrays_path = staging / "lidar-pack.npz"
|
||||
np.savez_compressed(
|
||||
arrays_path,
|
||||
frame_indices=np.arange(count, dtype=np.int64),
|
||||
source_frame_indices=source_indices,
|
||||
session_seconds=session_seconds,
|
||||
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,
|
||||
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": PACK_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-replay-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_bytes(
|
||||
json.dumps(
|
||||
manifest,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
+ b"\n"
|
||||
)
|
||||
os.replace(staging, output)
|
||||
try:
|
||||
validation = LidarReplayPack(output, expected_job_id=upstream.job.job_id)
|
||||
validation.close()
|
||||
except BaseException:
|
||||
shutil.rmtree(output, ignore_errors=True)
|
||||
raise
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"pack_id": pack_id,
|
||||
"output": str(output),
|
||||
"frames": count,
|
||||
"lidar_frames": int(available.sum()),
|
||||
"points": int(offsets[-1]),
|
||||
"byte_length": (output / "lidar-pack.npz").stat().st_size,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build a content-addressed LAB E10 LiDAR/pose replay pack from accepted E6."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import numpy as np
|
||||
from e10_fusion_runtime import PACK_SCHEMA, canonical_json, sha256
|
||||
|
||||
from k1link.compute import validate_tracked_fusion_qualification_result
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_overlay import (
|
||||
_load_calibration_snapshot,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
Kb4ProjectionProfile,
|
||||
)
|
||||
|
||||
|
||||
def arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job", type=Path, required=True)
|
||||
parser.add_argument("--tracking", type=Path, required=True)
|
||||
parser.add_argument("--semantic", type=Path, required=True)
|
||||
parser.add_argument("--e6-result", type=Path, required=True)
|
||||
parser.add_argument("--session", 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 load_e7_runner() -> ModuleType:
|
||||
path = Path(__file__).with_name("run_e7_replay_realtime.py").resolve(strict=True)
|
||||
spec = importlib.util.spec_from_file_location("missioncore_e10_e7_adapter", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("LAB E7 adapter cannot be loaded")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = arguments()
|
||||
upstream = validate_tracked_fusion_qualification_result(
|
||||
args.job,
|
||||
args.tracking,
|
||||
args.semantic,
|
||||
args.e6_result,
|
||||
)
|
||||
session = args.session.resolve(strict=True)
|
||||
if session.name != upstream.job.session_id:
|
||||
raise RuntimeError("LAB E10 session differs from the accepted E6 input")
|
||||
calibration, calibration_sha256 = _load_calibration_snapshot(
|
||||
args.calibration.resolve(strict=True)
|
||||
)
|
||||
if calibration_sha256 != upstream.semantic.calibration_sha256:
|
||||
raise RuntimeError("LAB E10 calibration differs from the accepted E6 input")
|
||||
projection = Kb4ProjectionProfile.from_factory_calibration(
|
||||
calibration,
|
||||
upstream.job.source_id,
|
||||
)
|
||||
adapter = load_e7_runner()
|
||||
frames = adapter._prepare_frames(
|
||||
e6=upstream,
|
||||
session=session,
|
||||
selection_start=0,
|
||||
selection_count=upstream.frame_count,
|
||||
)
|
||||
if len(frames) != upstream.frame_count:
|
||||
raise RuntimeError("LAB E10 E6 replay frame count changed")
|
||||
|
||||
frame_indices = np.arange(len(frames), dtype=np.int64)
|
||||
source_indices = np.asarray(
|
||||
[int(frame.frame["source_frame_index"]) for frame in frames], dtype=np.int64
|
||||
)
|
||||
session_seconds = np.asarray(
|
||||
[float(frame.frame["session_seconds"]) for frame in frames], dtype=np.float64
|
||||
)
|
||||
available = np.asarray([frame.position_map_xyz is not None for frame in frames], dtype=np.bool_)
|
||||
offsets = [0]
|
||||
clouds = []
|
||||
positions = np.full((len(frames), 3), np.nan, dtype=np.float64)
|
||||
quaternions = np.full((len(frames), 4), np.nan, dtype=np.float64)
|
||||
lidar_delta = np.full((len(frames),), np.nan, dtype=np.float64)
|
||||
pose_delta = np.full((len(frames),), np.nan, dtype=np.float64)
|
||||
for index, frame in enumerate(frames):
|
||||
cloud = np.asarray(frame.cloud_map, dtype=np.float32)
|
||||
clouds.append(cloud)
|
||||
offsets.append(offsets[-1] + cloud.shape[0])
|
||||
if frame.position_map_xyz is not None and frame.orientation_map_from_lidar_xyzw is not None:
|
||||
positions[index] = frame.position_map_xyz
|
||||
quaternions[index] = frame.orientation_map_from_lidar_xyzw
|
||||
lidar_delta[index] = float(frame.frame["lidar_camera_delta_ms"])
|
||||
pose_delta[index] = float(frame.frame["pose_point_delta_ms"])
|
||||
|
||||
identity = {
|
||||
"schema_version": PACK_SCHEMA,
|
||||
"job_id": upstream.job.job_id,
|
||||
"input_sha256": upstream.job.input_sha256,
|
||||
"session_id": upstream.job.session_id,
|
||||
"source_id": upstream.job.source_id,
|
||||
"camera_slot": upstream.semantic.calibration_slot,
|
||||
"calibration_sha256": calibration_sha256,
|
||||
"e6_result_id": upstream.result_id,
|
||||
"frame_count": len(frames),
|
||||
"source_start_frame_index": int(source_indices[0]),
|
||||
"source_end_frame_index": int(source_indices[-1]),
|
||||
"timeline_start_seconds": float(session_seconds[0]),
|
||||
"timeline_end_seconds": float(session_seconds[-1]),
|
||||
"available_lidar_frames": int(available.sum()),
|
||||
"point_count": int(offsets[-1]),
|
||||
"temporal_binding": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"projection": {
|
||||
"model": "kb4",
|
||||
"width": projection.width,
|
||||
"height": projection.height,
|
||||
"source_coordinates": "k1-map",
|
||||
"target_camera": upstream.job.source_id,
|
||||
},
|
||||
"producer_sha256": sha256(Path(__file__).resolve(strict=True)),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(canonical_json(identity)).hexdigest()
|
||||
pack_id = f"e10-lidar-pack-{identity_sha256}"
|
||||
parent = args.output_root.resolve()
|
||||
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
output = parent / pack_id
|
||||
if output.exists():
|
||||
print(json.dumps({"pack_id": pack_id, "output": str(output), "reused": True}))
|
||||
return 0
|
||||
staging = parent / f".{pack_id}.{os.getpid()}.incomplete"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
try:
|
||||
arrays_path = staging / "lidar-pack.npz"
|
||||
np.savez_compressed(
|
||||
arrays_path,
|
||||
frame_indices=frame_indices,
|
||||
source_frame_indices=source_indices,
|
||||
session_seconds=session_seconds,
|
||||
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,
|
||||
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": PACK_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-replay-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),
|
||||
},
|
||||
}
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_bytes(
|
||||
json.dumps(
|
||||
manifest,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
+ b"\n"
|
||||
)
|
||||
os.replace(staging, output)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"pack_id": pack_id,
|
||||
"output": str(output),
|
||||
"frames": len(frames),
|
||||
"lidar_frames": int(available.sum()),
|
||||
"points": int(offsets[-1]),
|
||||
"byte_length": (output / "lidar-pack.npz").stat().st_size,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Extract a minimal content-addressed live projection pack from accepted E14 input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
SCHEMA = "missioncore.e15-live-projection-pack/v1"
|
||||
PACK_ARRAYS = {
|
||||
"intrinsic_fx_fy_cx_cy",
|
||||
"distortion_kb4",
|
||||
"t_camera_from_lidar",
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_object(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError("projection source manifest is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def build_projection_pack(
|
||||
source_root: Path,
|
||||
output_root: Path,
|
||||
*,
|
||||
expected_calibration_sha256: str,
|
||||
) -> Path:
|
||||
source = source_root.expanduser().resolve(strict=True)
|
||||
manifest_path = source / "manifest.json"
|
||||
arrays_path = source / "lidar-pack.npz"
|
||||
manifest = _read_object(manifest_path)
|
||||
identity = manifest.get("identity")
|
||||
if (
|
||||
not isinstance(identity, dict)
|
||||
or identity.get("calibration_sha256") != expected_calibration_sha256
|
||||
or identity.get("source_id") != "sensor.camera.right"
|
||||
or identity.get("camera_slot") != "camera_1"
|
||||
or manifest.get("artifact", {}).get("sha256") != _sha256(arrays_path)
|
||||
):
|
||||
raise RuntimeError("accepted LiDAR pack calibration binding changed")
|
||||
with np.load(arrays_path, allow_pickle=False) as source_arrays:
|
||||
if not PACK_ARRAYS.issubset(source_arrays.files):
|
||||
raise RuntimeError("accepted LiDAR pack projection arrays changed")
|
||||
intrinsic = np.asarray(source_arrays["intrinsic_fx_fy_cx_cy"], dtype=np.float64)
|
||||
distortion = np.asarray(source_arrays["distortion_kb4"], dtype=np.float64)
|
||||
transform = np.asarray(source_arrays["t_camera_from_lidar"], dtype=np.float64)
|
||||
if (
|
||||
intrinsic.shape != (4,)
|
||||
or distortion.shape != (4,)
|
||||
or transform.shape != (4, 4)
|
||||
or not np.isfinite(intrinsic).all()
|
||||
or not np.isfinite(distortion).all()
|
||||
or not np.isfinite(transform).all()
|
||||
):
|
||||
raise RuntimeError("live projection arrays are invalid")
|
||||
|
||||
pack_identity = {
|
||||
"schema_version": SCHEMA,
|
||||
"source_id": "sensor.camera.right",
|
||||
"calibration_slot": "camera_1",
|
||||
"calibration_sha256": expected_calibration_sha256,
|
||||
"resolution": [800, 600],
|
||||
"projection_model": "kb4",
|
||||
"coordinate_contract": "k1-map-to-camera_1-via-inverse-map-pose",
|
||||
"source_lidar_pack_id": manifest.get("pack_id"),
|
||||
"source_lidar_pack_identity_sha256": manifest.get("identity_sha256"),
|
||||
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical(pack_identity)).hexdigest()
|
||||
pack_id = f"e15-live-projection-{identity_sha256}"
|
||||
parent = output_root.expanduser().resolve()
|
||||
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
destination = parent / pack_id
|
||||
if destination.exists():
|
||||
validate_projection_pack(destination, expected_calibration_sha256)
|
||||
return destination
|
||||
staging = parent / f".{pack_id}.{os.getpid()}.incomplete"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
try:
|
||||
output_arrays = staging / "projection.npz"
|
||||
np.savez(
|
||||
output_arrays,
|
||||
intrinsic_fx_fy_cx_cy=intrinsic,
|
||||
distortion_kb4=distortion,
|
||||
t_camera_from_lidar=transform,
|
||||
)
|
||||
document = {
|
||||
"schema_version": SCHEMA,
|
||||
"pack_id": pack_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": pack_identity,
|
||||
"created_at_utc": datetime.now(UTC)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z"),
|
||||
"classification": "calibration-only-no-recorded-sensor-frames",
|
||||
"artifact": {
|
||||
"path": output_arrays.name,
|
||||
"byte_length": output_arrays.stat().st_size,
|
||||
"sha256": _sha256(output_arrays),
|
||||
},
|
||||
}
|
||||
(staging / "manifest.json").write_bytes(
|
||||
json.dumps(document, indent=2, sort_keys=True).encode() + b"\n"
|
||||
)
|
||||
os.replace(staging, destination)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
validate_projection_pack(destination, expected_calibration_sha256)
|
||||
return destination
|
||||
|
||||
|
||||
def validate_projection_pack(root: Path, expected_calibration_sha256: str) -> dict[str, Any]:
|
||||
resolved = root.expanduser().resolve(strict=True)
|
||||
manifest = _read_object(resolved / "manifest.json")
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
artifact = manifest.get("artifact")
|
||||
arrays_path = resolved / "projection.npz"
|
||||
if (
|
||||
manifest.get("schema_version") != SCHEMA
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != SCHEMA
|
||||
or identity.get("calibration_sha256") != expected_calibration_sha256
|
||||
or not isinstance(identity_sha256, str)
|
||||
or hashlib.sha256(_canonical(identity)).hexdigest() != identity_sha256
|
||||
or resolved.name != f"e15-live-projection-{identity_sha256}"
|
||||
or manifest.get("pack_id") != resolved.name
|
||||
or not isinstance(artifact, dict)
|
||||
or artifact.get("path") != arrays_path.name
|
||||
or artifact.get("byte_length") != arrays_path.stat().st_size
|
||||
or artifact.get("sha256") != _sha256(arrays_path)
|
||||
):
|
||||
raise RuntimeError("live projection pack identity is invalid")
|
||||
with np.load(arrays_path, allow_pickle=False) as arrays:
|
||||
if set(arrays.files) != PACK_ARRAYS:
|
||||
raise RuntimeError("live projection pack arrays changed")
|
||||
if (
|
||||
arrays["intrinsic_fx_fy_cx_cy"].shape != (4,)
|
||||
or arrays["distortion_kb4"].shape != (4,)
|
||||
or arrays["t_camera_from_lidar"].shape != (4, 4)
|
||||
):
|
||||
raise RuntimeError("live projection pack shapes changed")
|
||||
return manifest
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
parser.add_argument("--calibration-sha256", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
output = build_projection_pack(
|
||||
args.source_root,
|
||||
args.output_root,
|
||||
expected_calibration_sha256=args.calibration_sha256,
|
||||
)
|
||||
print(json.dumps({"projection_pack": str(output)}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build the minimal hash-addressed K1 runtime imported by the E15 worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA = "missioncore.e15-worker-package/v1"
|
||||
COPIED_FILES = (
|
||||
"k1link/__init__.py",
|
||||
"k1link/compute/live_perception.py",
|
||||
"k1link/data_plane/__init__.py",
|
||||
"k1link/data_plane/views.py",
|
||||
"k1link/device_plugins/__init__.py",
|
||||
"k1link/device_plugins/xgrids_k1/protocol/__init__.py",
|
||||
"k1link/device_plugins/xgrids_k1/protocol/normalizer.py",
|
||||
"k1link/device_plugins/xgrids_k1/protocol/protobuf_wire.py",
|
||||
"k1link/device_plugins/xgrids_k1/protocol/streams.py",
|
||||
)
|
||||
GENERATED_FILES = {
|
||||
"k1link/compute/__init__.py": (
|
||||
'"""Minimal E15 worker projection; import live_perception explicitly."""\n'
|
||||
),
|
||||
"k1link/device_plugins/xgrids_k1/__init__.py": (
|
||||
'"""Minimal E15 K1 decoder projection; no device-runtime side effects."""\n'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _identity(source_root: Path) -> dict[str, Any]:
|
||||
source_files = []
|
||||
for relative in COPIED_FILES:
|
||||
source = source_root / relative
|
||||
if not source.is_file() or source.is_symlink():
|
||||
raise RuntimeError(f"E15 worker source is not a regular file: {relative}")
|
||||
source_files.append({"path": relative, "sha256": _sha256(source)})
|
||||
return {
|
||||
"schema_version": SCHEMA,
|
||||
"classification": "minimal-live-worker-import-projection",
|
||||
"source_files": source_files,
|
||||
"generated_initializers": {
|
||||
path: hashlib.sha256(content.encode()).hexdigest()
|
||||
for path, content in sorted(GENERATED_FILES.items())
|
||||
},
|
||||
"imports": [
|
||||
"k1link.compute.live_perception.LiveSensorSynchronizer",
|
||||
"k1link.data_plane.DecodedPointCloudView",
|
||||
"k1link.data_plane.DecodedPoseView",
|
||||
"k1link.device_plugins.xgrids_k1.protocol.normalizer.normalize_k1_message",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def validate_worker_package(root: Path, *, allow_staging: bool = False) -> dict[str, Any]:
|
||||
resolved = root.resolve(strict=True)
|
||||
manifest_path = resolved / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
artifacts = manifest.get("artifacts")
|
||||
expected_paths = {*COPIED_FILES, *GENERATED_FILES, "manifest.json"}
|
||||
actual_paths = {
|
||||
path.relative_to(resolved).as_posix()
|
||||
for path in resolved.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
if (
|
||||
manifest.get("schema_version") != SCHEMA
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or hashlib.sha256(_canonical(identity)).hexdigest() != identity_sha256
|
||||
or manifest.get("package_id") != f"e15-worker-package-{identity_sha256}"
|
||||
or (
|
||||
resolved.name != manifest.get("package_id")
|
||||
and not (
|
||||
allow_staging
|
||||
and resolved.name.startswith(f".{manifest.get('package_id')}.")
|
||||
and resolved.name.endswith(".tmp")
|
||||
)
|
||||
)
|
||||
or not isinstance(artifacts, list)
|
||||
or actual_paths != expected_paths
|
||||
):
|
||||
raise RuntimeError("E15 worker package identity is invalid")
|
||||
artifact_paths = set()
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
raise RuntimeError("E15 worker package artifact is invalid")
|
||||
relative = str(artifact.get("path", ""))
|
||||
path = resolved / relative
|
||||
if (
|
||||
relative not in expected_paths - {"manifest.json"}
|
||||
or relative in artifact_paths
|
||||
or not path.is_file()
|
||||
or path.is_symlink()
|
||||
or artifact.get("byte_length") != path.stat().st_size
|
||||
or artifact.get("sha256") != _sha256(path)
|
||||
):
|
||||
raise RuntimeError("E15 worker package artifact changed")
|
||||
artifact_paths.add(relative)
|
||||
if artifact_paths != expected_paths - {"manifest.json"}:
|
||||
raise RuntimeError("E15 worker package artifact set changed")
|
||||
return manifest
|
||||
|
||||
|
||||
def build_worker_package(source_root: Path, output_root: Path) -> Path:
|
||||
source = source_root.resolve(strict=True)
|
||||
output = output_root.resolve()
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
identity = _identity(source)
|
||||
identity_sha256 = hashlib.sha256(_canonical(identity)).hexdigest()
|
||||
package_id = f"e15-worker-package-{identity_sha256}"
|
||||
destination = output / package_id
|
||||
if destination.exists():
|
||||
validate_worker_package(destination)
|
||||
return destination
|
||||
staging = output / f".{package_id}.{uuid.uuid4().hex}.tmp"
|
||||
staging.mkdir(mode=0o700)
|
||||
try:
|
||||
for relative in COPIED_FILES:
|
||||
target = staging / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source / relative, target)
|
||||
for relative, content in GENERATED_FILES.items():
|
||||
target = staging / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
artifacts = []
|
||||
for relative in sorted((*COPIED_FILES, *GENERATED_FILES)):
|
||||
path = staging / relative
|
||||
artifacts.append(
|
||||
{
|
||||
"path": relative,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": SCHEMA,
|
||||
"package_id": package_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": datetime.now(UTC)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z"),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
with (staging / "manifest.json").open("x", encoding="utf-8", newline="\n") as stream:
|
||||
json.dump(manifest, stream, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
validate_worker_package(staging, allow_staging=True)
|
||||
staging.replace(destination)
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
validate_worker_package(destination)
|
||||
return destination
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
destination = build_worker_package(args.source_root, args.output_root)
|
||||
print(destination)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prepare immutable E1 preprocessing and frame-selection inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.compute.qualification import (
|
||||
DEFAULT_QUALIFICATION_FRAME_COUNT,
|
||||
prepare_recorded_qualification_slice,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.valid_fov import (
|
||||
DEFAULT_EDGE_MARGIN_PIXELS,
|
||||
prepare_k1_valid_fov_mask,
|
||||
)
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job-root", type=Path, required=True)
|
||||
parser.add_argument("--calibration-root", type=Path, required=True)
|
||||
parser.add_argument("--source-id", required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--sample-count",
|
||||
type=int,
|
||||
default=DEFAULT_QUALIFICATION_FRAME_COUNT,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--edge-margin-pixels",
|
||||
type=float,
|
||||
default=DEFAULT_EDGE_MARGIN_PIXELS,
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
mask = prepare_k1_valid_fov_mask(
|
||||
calibration_snapshot_root=args.calibration_root,
|
||||
source_id=args.source_id,
|
||||
output_root=args.output_root / "valid-fov",
|
||||
edge_margin_pixels=args.edge_margin_pixels,
|
||||
)
|
||||
qualification = prepare_recorded_qualification_slice(
|
||||
job_root=args.job_root,
|
||||
output_root=args.output_root / "qualification-slices",
|
||||
sample_count=args.sample_count,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.e1-qualification-preparation/v1",
|
||||
"valid_fov": {
|
||||
"generation_id": mask.generation_id,
|
||||
"manifest": str(mask.manifest_path),
|
||||
"mask": str(mask.mask_path),
|
||||
"calibration_sha256": mask.calibration_sha256,
|
||||
"source_id": mask.source_id,
|
||||
"calibration_slot": mask.calibration_slot,
|
||||
"center_xy": list(mask.center_xy),
|
||||
"radius_pixels": mask.radius_pixels,
|
||||
"crop_xyxy_exclusive": list(mask.crop_xyxy),
|
||||
"valid_pixel_count": mask.valid_pixel_count,
|
||||
"valid_fraction": mask.valid_fraction,
|
||||
},
|
||||
"qualification_slice": {
|
||||
"generation_id": qualification.generation_id,
|
||||
"manifest": str(qualification.manifest_path),
|
||||
"job_id": qualification.job_id,
|
||||
"input_sha256": qualification.input_sha256,
|
||||
"policy": qualification.policy,
|
||||
"source_frame_count": qualification.source_frame_count,
|
||||
"selected_frame_count": len(qualification.frames),
|
||||
"first_frame_index": qualification.frames[0].frame_index,
|
||||
"last_frame_index": qualification.frames[-1].frame_index,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build or validate the immutable LAB E2 CVAT annotation handoff."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import k1link.compute.annotation_workspace as workspace_module
|
||||
from k1link.compute import prepare_annotation_workspace, validate_annotation_workspace
|
||||
|
||||
|
||||
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()
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
prepare = commands.add_parser("prepare")
|
||||
prepare.add_argument("--evaluation-pack", type=Path, required=True)
|
||||
prepare.add_argument("--prelabels", type=Path, required=True)
|
||||
prepare.add_argument("--valid-fov-root", type=Path, required=True)
|
||||
prepare.add_argument("--output-root", type=Path, required=True)
|
||||
|
||||
validate = commands.add_parser("validate")
|
||||
validate.add_argument("--workspace", type=Path, required=True)
|
||||
validate.add_argument("--evaluation-pack", type=Path, required=True)
|
||||
validate.add_argument("--prelabels", type=Path, required=True)
|
||||
validate.add_argument("--valid-fov-root", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
if args.command == "prepare":
|
||||
result = prepare_annotation_workspace(
|
||||
evaluation_pack_root=args.evaluation_pack,
|
||||
prelabels_root=args.prelabels,
|
||||
valid_fov_root=args.valid_fov_root,
|
||||
output_root=args.output_root,
|
||||
producer_files=(
|
||||
(
|
||||
"annotation_workspace.py",
|
||||
_sha256(Path(str(workspace_module.__file__)).resolve(strict=True)),
|
||||
),
|
||||
(
|
||||
"prepare_e2_annotation_workspace.py",
|
||||
_sha256(Path(__file__).resolve(strict=True)),
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
result = validate_annotation_workspace(
|
||||
args.workspace,
|
||||
evaluation_pack_root=args.evaluation_pack,
|
||||
prelabels_root=args.prelabels,
|
||||
valid_fov_root=args.valid_fov_root,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"generation_id": result.generation_id,
|
||||
"root": str(result.root),
|
||||
"frame_count": result.frame_count,
|
||||
"draft_instance_count": result.draft_instance_count,
|
||||
"ground_truth": False,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Review and seal the LAB E2 recorded-perception evaluation pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
import k1link.compute.evaluation_pack as evaluation_pack_module
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.compute import (
|
||||
EvaluationFrameRequest,
|
||||
prepare_recorded_evaluation_pack,
|
||||
validate_camera_compute_job,
|
||||
validate_recorded_qualification_slice,
|
||||
)
|
||||
|
||||
CANDIDATE_SCHEMA = "missioncore.e2-evaluation-candidates/v1"
|
||||
SELECTION_SCHEMA = "missioncore.e2-evaluation-selection/v1"
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
candidates = commands.add_parser("candidates")
|
||||
candidates.add_argument("--job-root", type=Path, required=True)
|
||||
candidates.add_argument("--qualification-root", type=Path, required=True)
|
||||
candidates.add_argument("--output-root", type=Path, required=True)
|
||||
|
||||
seal = commands.add_parser("seal")
|
||||
seal.add_argument("--job-root", type=Path, required=True)
|
||||
seal.add_argument("--qualification-root", type=Path, required=True)
|
||||
seal.add_argument("--valid-fov-root", type=Path, required=True)
|
||||
seal.add_argument("--selection", type=Path, required=True)
|
||||
seal.add_argument("--output-root", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, check=True, text=True, capture_output=True)
|
||||
|
||||
|
||||
def _ffmpeg_version() -> str:
|
||||
completed = _run(["ffmpeg", "-version"])
|
||||
return completed.stdout.splitlines()[0].strip()
|
||||
|
||||
|
||||
def _reconstruct_stream(job_root: Path, destination: Path) -> None:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
epoch = (
|
||||
job.job_root
|
||||
/ "input"
|
||||
/ "camera"
|
||||
/ job.source_id
|
||||
/ f"epoch-{job.codec_epoch}"
|
||||
)
|
||||
with destination.open("xb") as output:
|
||||
with (epoch / "init.mp4").open("rb") as source:
|
||||
shutil.copyfileobj(source, output, 1024 * 1024)
|
||||
for sequence in range(1, job.segment_count + 1):
|
||||
with (epoch / "segments" / f"{sequence}.m4s").open("rb") as source:
|
||||
shutil.copyfileobj(source, output, 1024 * 1024)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
|
||||
|
||||
def _timeline(stream_path: Path, start_seconds: float, expected_count: int) -> list[float]:
|
||||
completed = _run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"frame=best_effort_timestamp_time",
|
||||
"-of",
|
||||
"json",
|
||||
str(stream_path),
|
||||
]
|
||||
)
|
||||
document = json.loads(completed.stdout)
|
||||
frames = document.get("frames")
|
||||
if not isinstance(frames, list) or len(frames) != expected_count:
|
||||
raise RuntimeError("decoded camera timestamp count differs from the compute job")
|
||||
epoch_values = [float(frame["best_effort_timestamp_time"]) for frame in frames]
|
||||
first = epoch_values[0]
|
||||
values = [start_seconds + value - first for value in epoch_values]
|
||||
if any(right <= left for left, right in zip(values, values[1:], strict=False)):
|
||||
raise RuntimeError("decoded camera timestamps are not strictly monotonic")
|
||||
return values
|
||||
|
||||
|
||||
def _extract_frames(stream_path: Path, indices: list[int], output_root: Path) -> None:
|
||||
if not indices or indices != sorted(set(indices)):
|
||||
raise RuntimeError("frame extraction indices must be sorted and unique")
|
||||
output_root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
expression = "+".join(f"eq(n\\,{index})" for index in indices)
|
||||
pattern = output_root / "selected-%06d.png"
|
||||
_run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(stream_path),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-vf",
|
||||
f"select={expression}",
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
str(pattern),
|
||||
]
|
||||
)
|
||||
extracted = sorted(output_root.glob("selected-*.png"))
|
||||
if len(extracted) != len(indices):
|
||||
raise RuntimeError("ffmpeg did not extract the requested frame set")
|
||||
for source, frame_index in zip(extracted, indices, strict=True):
|
||||
destination = output_root / f"frame-{frame_index:06d}.png"
|
||||
source.replace(destination)
|
||||
os.chmod(destination, 0o600)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _contact_sheets(
|
||||
frames_root: Path,
|
||||
indices: list[int],
|
||||
timestamps: list[float],
|
||||
output_root: Path,
|
||||
) -> list[dict[str, Any]]:
|
||||
output_root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
columns = 8
|
||||
rows = 4
|
||||
thumb_size = (200, 150)
|
||||
label_height = 25
|
||||
font = ImageFont.load_default(size=15)
|
||||
records: list[dict[str, Any]] = []
|
||||
page_size = columns * rows
|
||||
for page, offset in enumerate(range(0, len(indices), page_size), start=1):
|
||||
page_indices = indices[offset : offset + page_size]
|
||||
canvas = Image.new(
|
||||
"RGB",
|
||||
(columns * thumb_size[0], rows * (thumb_size[1] + label_height)),
|
||||
(18, 18, 18),
|
||||
)
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
for position, frame_index in enumerate(page_indices):
|
||||
row, column = divmod(position, columns)
|
||||
x = column * thumb_size[0]
|
||||
y = row * (thumb_size[1] + label_height)
|
||||
with Image.open(frames_root / f"frame-{frame_index:06d}.png") as opened:
|
||||
image = opened.convert("RGB")
|
||||
image.thumbnail(thumb_size, Image.Resampling.LANCZOS)
|
||||
canvas.paste(image, (x, y))
|
||||
draw.text(
|
||||
(x + 4, y + thumb_size[1] + 3),
|
||||
f"f={frame_index} t={timestamps[frame_index]:.3f}s",
|
||||
fill=(240, 240, 240),
|
||||
font=font,
|
||||
)
|
||||
path = output_root / f"contact-sheet-{page:02d}.png"
|
||||
canvas.save(path, format="PNG", optimize=False)
|
||||
os.chmod(path, 0o600)
|
||||
records.append(
|
||||
{
|
||||
"path": path.name,
|
||||
"first_frame_index": page_indices[0],
|
||||
"last_frame_index": page_indices[-1],
|
||||
"frame_count": len(page_indices),
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _candidates(args: argparse.Namespace) -> int:
|
||||
job = validate_camera_compute_job(args.job_root)
|
||||
qualification = validate_recorded_qualification_slice(
|
||||
args.qualification_root,
|
||||
job_root=job.job_root,
|
||||
)
|
||||
output = args.output_root.expanduser()
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
published = False
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="missioncore-e2-") as temporary:
|
||||
stream_path = Path(temporary) / "camera.mp4"
|
||||
_reconstruct_stream(job.job_root, stream_path)
|
||||
timestamps = _timeline(stream_path, job.timeline_start_seconds, job.segment_count)
|
||||
indices = [frame.frame_index for frame in qualification.frames]
|
||||
frames_root = output / "frames"
|
||||
_extract_frames(stream_path, indices, frames_root)
|
||||
sheets = _contact_sheets(frames_root, indices, timestamps, output / "contact-sheets")
|
||||
manifest = {
|
||||
"schema_version": CANDIDATE_SCHEMA,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"state": "review-candidates",
|
||||
"job_id": job.job_id,
|
||||
"input_sha256": job.input_sha256,
|
||||
"qualification_generation_id": qualification.generation_id,
|
||||
"ffmpeg": _ffmpeg_version(),
|
||||
"frames": [
|
||||
{
|
||||
"qualification_order": order,
|
||||
"frame_index": frame.frame_index,
|
||||
"sequence": frame.sequence,
|
||||
"segment_sha256": frame.segment_sha256,
|
||||
"session_seconds": timestamps[frame.frame_index],
|
||||
"path": f"frames/frame-{frame.frame_index:06d}.png",
|
||||
"byte_length": (
|
||||
frames_root / f"frame-{frame.frame_index:06d}.png"
|
||||
).stat().st_size,
|
||||
"sha256": _sha256(frames_root / f"frame-{frame.frame_index:06d}.png"),
|
||||
}
|
||||
for order, frame in enumerate(qualification.frames, start=1)
|
||||
],
|
||||
"contact_sheets": sheets,
|
||||
"selection_guidance": {
|
||||
"anchor_target": 48,
|
||||
"temporal_clip_target": {"clips": 4, "frames_per_clip": 4},
|
||||
"total_target": 64,
|
||||
"required_coverage": [
|
||||
"person",
|
||||
"car_or_heavy_vehicle",
|
||||
"building_structure",
|
||||
"paved_or_unpaved_ground",
|
||||
"grass_and_woody_vegetation",
|
||||
"lens_boundary_hard_negative",
|
||||
"motion_or_occlusion",
|
||||
],
|
||||
},
|
||||
}
|
||||
write_json_atomic(output / "manifest.json", manifest)
|
||||
os.chmod(output / "manifest.json", 0o600)
|
||||
published = True
|
||||
finally:
|
||||
if not published and output.exists():
|
||||
shutil.rmtree(output)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def _load_selection(path: Path) -> tuple[EvaluationFrameRequest, ...]:
|
||||
document = json.loads(path.expanduser().read_text(encoding="utf-8"))
|
||||
rows = document.get("frames") if isinstance(document, dict) else None
|
||||
if document.get("schema_version") != SELECTION_SCHEMA or not isinstance(rows, list):
|
||||
raise RuntimeError("E2 selection document is incompatible")
|
||||
selection: list[EvaluationFrameRequest] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise RuntimeError("E2 selection frame is invalid")
|
||||
selection.append(
|
||||
EvaluationFrameRequest(
|
||||
frame_index=int(row["frame_index"]),
|
||||
role=str(row["role"]),
|
||||
group_id=str(row["group_id"]),
|
||||
)
|
||||
)
|
||||
return tuple(selection)
|
||||
|
||||
|
||||
def _write_timeline(path: Path, timestamps: list[float]) -> None:
|
||||
with path.open("x", encoding="utf-8", newline="\n") as stream:
|
||||
for frame_index, session_seconds in enumerate(timestamps):
|
||||
stream.write(
|
||||
json.dumps(
|
||||
{"frame_index": frame_index, "session_seconds": session_seconds},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _seal(args: argparse.Namespace) -> int:
|
||||
job = validate_camera_compute_job(args.job_root)
|
||||
selection = _load_selection(args.selection)
|
||||
indices = [frame.frame_index for frame in selection]
|
||||
with tempfile.TemporaryDirectory(prefix="missioncore-e2-") as temporary:
|
||||
temporary_root = Path(temporary)
|
||||
stream_path = temporary_root / "camera.mp4"
|
||||
frames_root = temporary_root / "frames"
|
||||
timeline_path = temporary_root / "timeline.jsonl"
|
||||
_reconstruct_stream(job.job_root, stream_path)
|
||||
timestamps = _timeline(stream_path, job.timeline_start_seconds, job.segment_count)
|
||||
_extract_frames(stream_path, indices, frames_root)
|
||||
_write_timeline(timeline_path, timestamps)
|
||||
pack = prepare_recorded_evaluation_pack(
|
||||
job_root=job.job_root,
|
||||
qualification_root=args.qualification_root,
|
||||
valid_fov_root=args.valid_fov_root,
|
||||
decoded_frames_root=frames_root,
|
||||
timeline_path=timeline_path,
|
||||
output_root=args.output_root,
|
||||
selection=selection,
|
||||
decoder_version=_ffmpeg_version(),
|
||||
selection_document_sha256=_sha256(args.selection.expanduser().resolve(strict=True)),
|
||||
producer_files=(
|
||||
(
|
||||
"evaluation_pack.py",
|
||||
_sha256(Path(str(evaluation_pack_module.__file__)).resolve(strict=True)),
|
||||
),
|
||||
(
|
||||
"prepare_e2_evaluation_pack.py",
|
||||
_sha256(Path(__file__).resolve(strict=True)),
|
||||
),
|
||||
),
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.e2-evaluation-preparation/v1",
|
||||
"generation_id": pack.generation_id,
|
||||
"root": str(pack.root),
|
||||
"job_id": pack.job_id,
|
||||
"input_sha256": pack.input_sha256,
|
||||
"qualification_generation_id": pack.qualification_generation_id,
|
||||
"valid_fov_generation_id": pack.valid_fov_generation_id,
|
||||
"calibration_sha256": pack.calibration_sha256,
|
||||
"frame_count": len(pack.frames),
|
||||
"anchor_count": sum(frame.role == "anchor" for frame in pack.frames),
|
||||
"temporal_frame_count": sum(frame.role == "temporal" for frame in pack.frames),
|
||||
"annotation_state": "unannotated",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
if args.command == "candidates":
|
||||
return _candidates(args)
|
||||
if args.command == "seal":
|
||||
return _seal(args)
|
||||
raise RuntimeError("unknown E2 command")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build an immutable CVAT review handoff for the LAB E3 control and challenger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
RESULT_SCHEMA = "missioncore.k1-e3-rectified-segmentation-result/v1"
|
||||
PACK_SCHEMA = "missioncore.perception-evaluation-pack/v1"
|
||||
LABEL_SCHEMA = "missioncore.perception-cvat-labels/v1"
|
||||
MANIFEST_SCHEMA = "missioncore.lab-e3-cvat-review-workspace/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.lab-e3-cvat-review-identity/v1"
|
||||
CONTROL = "eomt-fisheye-mask"
|
||||
CHALLENGER = "eomt-kb4-cubemap5-clahe"
|
||||
EXPECTED_FRAMES = 64
|
||||
PRIORITY_IMAGE_IDS = (59, 63, 62, 61, 60, 64, 50, 30, 17, 29)
|
||||
ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--evaluation-pack", type=Path, required=True)
|
||||
parser.add_argument("--e3-result", type=Path, required=True)
|
||||
parser.add_argument("--labels", type=Path, required=True)
|
||||
parser.add_argument("--images-zip", type=Path, required=True)
|
||||
parser.add_argument("--output-root", 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()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_object(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError(f"JSON root is not an object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _valid_sha256(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) == 64
|
||||
and all(character in "0123456789abcdef" for character in value)
|
||||
)
|
||||
|
||||
|
||||
def _safe_artifact(root: Path, encoded: object) -> Path:
|
||||
if not isinstance(encoded, str):
|
||||
raise RuntimeError("artifact path is not a string")
|
||||
relative = PurePosixPath(encoded)
|
||||
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
||||
raise RuntimeError("artifact path is unsafe")
|
||||
path = root.joinpath(*relative.parts).resolve(strict=True)
|
||||
if not path.is_file() or not path.is_relative_to(root):
|
||||
raise RuntimeError("artifact path escaped its root")
|
||||
return path
|
||||
|
||||
|
||||
def _artifact(path: Path, root: Path) -> dict[str, Any]:
|
||||
return {
|
||||
"path": path.relative_to(root).as_posix(),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
|
||||
|
||||
def _validate_pack(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
manifest = _read_object(root / "manifest.json")
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != PACK_SCHEMA
|
||||
or not isinstance(identity, dict)
|
||||
or not _valid_sha256(identity_sha256)
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or manifest.get("generation_id") != f"evaluation-pack-{identity_sha256}"
|
||||
or identity.get("source_id") != "sensor.camera.right"
|
||||
or identity.get("calibration_slot") != "camera_1"
|
||||
):
|
||||
raise RuntimeError("evaluation pack is incompatible")
|
||||
frames = identity.get("frames")
|
||||
if (
|
||||
not isinstance(frames, list)
|
||||
or len(frames) != EXPECTED_FRAMES
|
||||
or [frame.get("image_id") for frame in frames] != list(range(1, 65))
|
||||
):
|
||||
raise RuntimeError("evaluation pack frame contract changed")
|
||||
return manifest, frames
|
||||
|
||||
|
||||
def _validate_result(root: Path, pack: dict[str, Any]) -> dict[str, Any]:
|
||||
result = _read_object(root / "result.json")
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("ground_truth") is not False
|
||||
or not isinstance(identity, dict)
|
||||
or not _valid_sha256(identity_sha256)
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or result.get("result_id") != f"e3-segmentation-{identity_sha256}"
|
||||
or root.name != result.get("result_id")
|
||||
or identity.get("evaluation_pack_id") != pack["generation_id"]
|
||||
or identity.get("evaluation_identity_sha256") != pack["identity_sha256"]
|
||||
or identity.get("frame_count") != EXPECTED_FRAMES
|
||||
or CONTROL not in identity.get("variants", ())
|
||||
or CHALLENGER not in identity.get("variants", ())
|
||||
):
|
||||
raise RuntimeError("LAB E3 result is incompatible")
|
||||
artifacts = result.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise RuntimeError("LAB E3 artifact list is invalid")
|
||||
descriptors = {
|
||||
item.get("path"): item
|
||||
for item in artifacts
|
||||
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
||||
}
|
||||
for variant in (CONTROL, CHALLENGER):
|
||||
for image_id in range(1, 65):
|
||||
encoded = f"semantic-masks/{variant}/image-{image_id:03d}.png"
|
||||
descriptor = descriptors.get(encoded)
|
||||
path = _safe_artifact(root, encoded)
|
||||
if (
|
||||
not isinstance(descriptor, dict)
|
||||
or path.stat().st_size != descriptor.get("bytes")
|
||||
or not _valid_sha256(descriptor.get("sha256"))
|
||||
or _sha256(path) != descriptor["sha256"]
|
||||
):
|
||||
raise RuntimeError(f"LAB E3 semantic artifact changed: {encoded}")
|
||||
return result
|
||||
|
||||
|
||||
def _semantic_labels(document: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if document.get("schema_version") != LABEL_SCHEMA:
|
||||
raise RuntimeError("CVAT label document is incompatible")
|
||||
raw = document.get("semantic_task")
|
||||
if not isinstance(raw, list):
|
||||
raise RuntimeError("CVAT semantic labels are absent")
|
||||
labels = [
|
||||
{"id": 0, "name": "background", "color": "#000000"},
|
||||
*[
|
||||
{"id": int(item["id"]), "name": str(item["name"]), "color": str(item["color"])}
|
||||
for item in raw
|
||||
],
|
||||
]
|
||||
if [label["id"] for label in labels] != list(range(16)) + [255]:
|
||||
raise RuntimeError("CVAT semantic taxonomy changed")
|
||||
return labels
|
||||
|
||||
|
||||
def _rgb(value: str) -> tuple[int, int, int]:
|
||||
if len(value) != 7 or not value.startswith("#"):
|
||||
raise RuntimeError("CVAT label color is invalid")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def _zip_info(name: str) -> zipfile.ZipInfo:
|
||||
info = zipfile.ZipInfo(name, date_time=ZIP_TIMESTAMP)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
return info
|
||||
|
||||
|
||||
def _write_segmentation_zip(
|
||||
destination: Path,
|
||||
*,
|
||||
result_root: Path,
|
||||
variant: str,
|
||||
frames: list[dict[str, Any]],
|
||||
labels: list[dict[str, Any]],
|
||||
) -> None:
|
||||
palette = np.zeros((256, 3), dtype=np.uint8)
|
||||
for label in labels:
|
||||
palette[label["id"]] = _rgb(label["color"])
|
||||
labelmap = "".join(
|
||||
f"{label['name']}:{','.join(str(value) for value in _rgb(label['color']))}::\n"
|
||||
for label in labels
|
||||
).encode()
|
||||
stems = [
|
||||
f"image-{frame['image_id']:03d}-frame-{frame['frame_index']:06d}"
|
||||
for frame in frames
|
||||
]
|
||||
with zipfile.ZipFile(destination, mode="x", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr(_zip_info("labelmap.txt"), labelmap)
|
||||
archive.writestr(
|
||||
_zip_info("ImageSets/Segmentation/default.txt"),
|
||||
("\n".join(stems) + "\n").encode(),
|
||||
)
|
||||
for image_id, stem in enumerate(stems, start=1):
|
||||
source = result_root / "semantic-masks" / variant / f"image-{image_id:03d}.png"
|
||||
with Image.open(source) as opened:
|
||||
indexed = np.asarray(opened.convert("L"), dtype=np.uint8)
|
||||
if indexed.shape != (600, 800) or np.any(~np.isin(indexed, list(range(16)))):
|
||||
raise RuntimeError(f"semantic mask taxonomy changed: {source}")
|
||||
rgb = palette[indexed]
|
||||
from io import BytesIO
|
||||
|
||||
stream = BytesIO()
|
||||
Image.fromarray(rgb, mode="RGB").save(stream, format="PNG", optimize=True)
|
||||
archive.writestr(
|
||||
_zip_info(f"SegmentationClass/{stem}.png"),
|
||||
stream.getvalue(),
|
||||
)
|
||||
|
||||
|
||||
def _validate_existing(root: Path, identity_sha256: str) -> dict[str, Any]:
|
||||
manifest = _read_object(root / "manifest.json")
|
||||
if (
|
||||
manifest.get("schema_version") != MANIFEST_SCHEMA
|
||||
or manifest.get("workspace_id") != f"e3-cvat-review-{identity_sha256}"
|
||||
or manifest.get("identity_sha256") != identity_sha256
|
||||
or manifest.get("ground_truth") is not False
|
||||
):
|
||||
raise RuntimeError("existing LAB E3 CVAT workspace is incompatible")
|
||||
for descriptor in manifest.get("artifacts", ()):
|
||||
path = _safe_artifact(root, descriptor.get("path"))
|
||||
if path.stat().st_size != descriptor.get("bytes") or _sha256(path) != descriptor.get(
|
||||
"sha256"
|
||||
):
|
||||
raise RuntimeError("existing LAB E3 CVAT artifact changed")
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
pack_root = args.evaluation_pack.resolve(strict=True)
|
||||
result_root = args.e3_result.resolve(strict=True)
|
||||
labels_path = args.labels.resolve(strict=True)
|
||||
images_zip = args.images_zip.resolve(strict=True)
|
||||
output_root = args.output_root.resolve()
|
||||
pack, frames = _validate_pack(pack_root)
|
||||
result = _validate_result(result_root, pack)
|
||||
labels_document = _read_object(labels_path)
|
||||
labels = _semantic_labels(labels_document)
|
||||
with zipfile.ZipFile(images_zip) as archive:
|
||||
if len(archive.namelist()) != EXPECTED_FRAMES:
|
||||
raise RuntimeError("CVAT image archive frame count changed")
|
||||
|
||||
identity = {
|
||||
"schema_version": IDENTITY_SCHEMA,
|
||||
"evaluation_pack_id": pack["generation_id"],
|
||||
"evaluation_identity_sha256": pack["identity_sha256"],
|
||||
"e3_result_id": result["result_id"],
|
||||
"e3_identity_sha256": result["identity_sha256"],
|
||||
"control_variant": CONTROL,
|
||||
"challenger_variant": CHALLENGER,
|
||||
"frame_count": EXPECTED_FRAMES,
|
||||
"labels_sha256": _sha256(labels_path),
|
||||
"images_zip_sha256": _sha256(images_zip),
|
||||
"priority_image_ids": list(PRIORITY_IMAGE_IDS),
|
||||
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
workspace_id = f"e3-cvat-review-{identity_sha256}"
|
||||
final_root = output_root / workspace_id
|
||||
if final_root.exists():
|
||||
manifest = _validate_existing(final_root.resolve(strict=True), identity_sha256)
|
||||
print(json.dumps({"state": "reused", **manifest}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
staging = output_root / f".{workspace_id}.{os.getpid()}.publish"
|
||||
staging.mkdir(mode=0o700)
|
||||
try:
|
||||
labels_output = staging / "labels.json"
|
||||
labels_output.write_text(json.dumps(labels_document, indent=2) + "\n", encoding="utf-8")
|
||||
for variant, filename in (
|
||||
(CONTROL, "control-fisheye-mask.zip"),
|
||||
(CHALLENGER, "challenger-kb4-cubemap5.zip"),
|
||||
):
|
||||
_write_segmentation_zip(
|
||||
staging / filename,
|
||||
result_root=result_root,
|
||||
variant=variant,
|
||||
frames=frames,
|
||||
labels=labels,
|
||||
)
|
||||
artifacts = [
|
||||
_artifact(path, staging)
|
||||
for path in sorted(staging.iterdir())
|
||||
if path.is_file()
|
||||
]
|
||||
manifest = {
|
||||
"schema_version": MANIFEST_SCHEMA,
|
||||
"workspace_id": workspace_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"ground_truth": False,
|
||||
"artifacts": artifacts,
|
||||
"next_gate": "two-pass CVAT review and reviewed export",
|
||||
}
|
||||
(staging / "manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
staging.replace(final_root)
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
print(
|
||||
json.dumps(
|
||||
{"state": "created", "workspace_id": workspace_id, "root": str(final_root)},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Source-paced RAVNOVES00 qualification of the live shadow transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from k1link.compute.live_perception import LiveIngressModality, LivePerceptionIngress
|
||||
from k1link.device_plugins.xgrids_k1.live_perception_shadow import (
|
||||
build_live_perception_shadow_router,
|
||||
ensure_live_shadow_token,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||
|
||||
REPORT_SCHEMA = "missioncore.e12-shadow-transport-source-report/v1"
|
||||
PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayEvent:
|
||||
modality: LiveIngressModality
|
||||
source_id: str
|
||||
source_sequence: int
|
||||
epoch_ns: int
|
||||
monotonic_ns: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _camera_events(epoch_root: Path, duration_seconds: float) -> list[ReplayEvent]:
|
||||
index_path = epoch_root / "index.jsonl"
|
||||
if not index_path.is_file():
|
||||
raise RuntimeError("camera index is missing")
|
||||
events: list[ReplayEvent] = []
|
||||
start_epoch_ns: int | None = None
|
||||
end_epoch_ns: int | None = None
|
||||
with index_path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
record = json.loads(line)
|
||||
epoch_ns = int(record["host_epoch_ns"])
|
||||
if start_epoch_ns is None:
|
||||
start_epoch_ns = epoch_ns
|
||||
end_epoch_ns = epoch_ns + int(duration_seconds * 1_000_000_000)
|
||||
assert end_epoch_ns is not None
|
||||
if epoch_ns > end_epoch_ns:
|
||||
break
|
||||
payload_path = epoch_root / str(record["path"])
|
||||
payload = payload_path.read_bytes()
|
||||
if len(payload) != int(record["length"]):
|
||||
raise RuntimeError("camera replay segment length differs from its index")
|
||||
if hashlib.sha256(payload).hexdigest() != record["sha256"]:
|
||||
raise RuntimeError("camera replay segment digest differs from its index")
|
||||
events.append(
|
||||
ReplayEvent(
|
||||
modality="camera-frame",
|
||||
source_id="sensor.camera.right",
|
||||
source_sequence=int(record["sequence"]),
|
||||
epoch_ns=epoch_ns,
|
||||
monotonic_ns=int(record["host_monotonic_ns"]),
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
if not events or start_epoch_ns is None:
|
||||
raise RuntimeError("camera replay interval is empty")
|
||||
init_payload = (epoch_root / "init.mp4").read_bytes()
|
||||
events.insert(
|
||||
0,
|
||||
ReplayEvent(
|
||||
modality="camera-init",
|
||||
source_id="sensor.camera.right",
|
||||
source_sequence=0,
|
||||
epoch_ns=start_epoch_ns - 1,
|
||||
monotonic_ns=events[0].monotonic_ns - 1,
|
||||
payload=init_payload,
|
||||
),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _mqtt_events(
|
||||
capture_path: Path,
|
||||
*,
|
||||
start_epoch_ns: int,
|
||||
duration_seconds: float,
|
||||
) -> list[ReplayEvent]:
|
||||
end_epoch_ns = start_epoch_ns + int(duration_seconds * 1_000_000_000)
|
||||
events: list[ReplayEvent] = []
|
||||
for message in iter_replay_messages(capture_path):
|
||||
if message.received_at_epoch_ns < start_epoch_ns:
|
||||
continue
|
||||
if message.received_at_epoch_ns > end_epoch_ns:
|
||||
break
|
||||
if message.topic.endswith("/lio_pcl") or message.topic == "RealtimePointcloud":
|
||||
modality: Literal["lidar", "pose"] = "lidar"
|
||||
elif message.topic.endswith("/lio_pose") or message.topic == "RealtimePath":
|
||||
modality = "pose"
|
||||
else:
|
||||
continue
|
||||
events.append(
|
||||
ReplayEvent(
|
||||
modality=modality,
|
||||
source_id=message.topic,
|
||||
source_sequence=message.sequence,
|
||||
epoch_ns=message.received_at_epoch_ns,
|
||||
monotonic_ns=message.received_monotonic_ns or 0,
|
||||
payload=message.payload,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _wait_for(predicate: object, *, timeout_seconds: float, label: str) -> None:
|
||||
if not callable(predicate):
|
||||
raise TypeError("wait predicate must be callable")
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(f"timed out waiting for {label}")
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> dict[str, object]:
|
||||
repository_root = Path(args.repository_root).expanduser().resolve()
|
||||
session_root = Path(args.session_root).expanduser().resolve()
|
||||
epoch_root = session_root / "media" / "sensor.camera.right" / "epoch-1"
|
||||
capture_path = session_root / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
camera = _camera_events(epoch_root, args.duration_seconds)
|
||||
mqtt = _mqtt_events(
|
||||
capture_path,
|
||||
start_epoch_ns=camera[1].epoch_ns,
|
||||
duration_seconds=args.duration_seconds,
|
||||
)
|
||||
events = sorted((*camera, *mqtt), key=lambda event: (event.epoch_ns, event.modality))
|
||||
if not mqtt:
|
||||
raise RuntimeError("selected replay interval contains no LiDAR or pose events")
|
||||
|
||||
ingress = LivePerceptionIngress()
|
||||
_, token = ensure_live_shadow_token(repository_root)
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
build_live_perception_shadow_router(
|
||||
ingress,
|
||||
PLUGIN_ID,
|
||||
bearer_token=token,
|
||||
)
|
||||
)
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1",
|
||||
port=args.port,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
)
|
||||
)
|
||||
server_thread = threading.Thread(target=server.run, name="e12-shadow-source", daemon=True)
|
||||
server_thread.start()
|
||||
_wait_for(lambda: server.started, timeout_seconds=10.0, label="E12 source server")
|
||||
|
||||
session_id = f"e12-ravnoves00-{int(time.time())}"
|
||||
ingress.begin_session(session_id)
|
||||
_wait_for(
|
||||
lambda: ingress.snapshot()["consumer_connected"],
|
||||
timeout_seconds=args.consumer_timeout_seconds,
|
||||
label="exclusive shadow worker",
|
||||
)
|
||||
started_monotonic = time.monotonic()
|
||||
source_start_ns = events[0].epoch_ns
|
||||
published: dict[str, int] = {}
|
||||
try:
|
||||
for event in events:
|
||||
target = started_monotonic + (event.epoch_ns - source_start_ns) / 1_000_000_000
|
||||
remaining = target - time.monotonic()
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
accepted = ingress.publish(
|
||||
modality=event.modality,
|
||||
source_id=event.source_id,
|
||||
source_sequence=event.source_sequence,
|
||||
captured_at_epoch_ns=event.epoch_ns,
|
||||
received_monotonic_ns=event.monotonic_ns,
|
||||
payload=event.payload,
|
||||
)
|
||||
if accepted:
|
||||
published[event.modality] = published.get(event.modality, 0) + 1
|
||||
ingress.end_session(session_id)
|
||||
_wait_for(
|
||||
lambda: not ingress.snapshot()["consumer_connected"],
|
||||
timeout_seconds=10.0,
|
||||
label="shadow worker report completion",
|
||||
)
|
||||
finally:
|
||||
ingress.end_session(session_id)
|
||||
ingress.close()
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=10.0)
|
||||
|
||||
completed_monotonic = time.monotonic()
|
||||
source_snapshot = ingress.snapshot()
|
||||
report: dict[str, object] = {
|
||||
"schema_version": REPORT_SCHEMA,
|
||||
"state": "completed",
|
||||
"session_id": session_id,
|
||||
"source": {
|
||||
"kind": "recorded-source-paced-near-live",
|
||||
"session": session_root.name,
|
||||
"camera_source": "sensor.camera.right",
|
||||
"camera_epoch": 1,
|
||||
"duration_seconds": args.duration_seconds,
|
||||
"mqtt_raw_sha256": _sha256(capture_path),
|
||||
"camera_index_sha256": _sha256(epoch_root / "index.jsonl"),
|
||||
},
|
||||
"events_selected": {
|
||||
"camera-init": sum(event.modality == "camera-init" for event in events),
|
||||
"camera-frame": sum(event.modality == "camera-frame" for event in events),
|
||||
"lidar": sum(event.modality == "lidar" for event in events),
|
||||
"pose": sum(event.modality == "pose" for event in events),
|
||||
},
|
||||
"events_admitted": published,
|
||||
"wall_seconds": completed_monotonic - started_monotonic,
|
||||
"ingress": source_snapshot,
|
||||
"authority": {
|
||||
"mode": "shadow-diagnostic-only",
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
output_root = repository_root / ".runtime" / "compute-experiments" / "e12"
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
temporary = output_root / f".{stamp}-e12-source.json.tmp"
|
||||
destination = output_root / f"{stamp}-e12-source.json"
|
||||
encoded = json.dumps(report, indent=2, sort_keys=True).encode("utf-8") + b"\n"
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(encoded)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(destination)
|
||||
report["report_path"] = str(destination)
|
||||
return report
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repository-root", default=str(root))
|
||||
parser.add_argument(
|
||||
"--session-root",
|
||||
default=str(
|
||||
root
|
||||
/ ".runtime"
|
||||
/ "mission-core"
|
||||
/ "evidence"
|
||||
/ "sessions"
|
||||
/ "20260720T065719Z_viewer_live"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=8012)
|
||||
parser.add_argument("--duration-seconds", type=float, default=15.0)
|
||||
parser.add_argument("--consumer-timeout-seconds", type=float, default=30.0)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(json.dumps(run(_arguments()), indent=2, sort_keys=True))
|
||||
|
|
@ -0,0 +1,809 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Qualify a bounded replay-as-live world-state loop on immutable LAB E6 data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import resource
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import rerun as rr
|
||||
from rerun.components import FillMode
|
||||
|
||||
import k1link.compute.live_perception as live_perception_module
|
||||
import k1link.compute.live_replay_qualification as live_validator_module
|
||||
from k1link.compute import (
|
||||
TELEMETRY_SCHEMA,
|
||||
LatestWinsQueue,
|
||||
WorldStateProjector,
|
||||
classify_health,
|
||||
validate_live_replay_qualification_result,
|
||||
validate_tracked_fusion_qualification_result,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
map_points_to_lidar,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import read_capture_clock_origin
|
||||
|
||||
PROFILE_SCHEMA = "missioncore.e7-replay-realtime-profile/v1"
|
||||
RESULT_SCHEMA = "missioncore.e7-live-replay-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e7-live-replay-identity/v1"
|
||||
REPORT_SCHEMA = "missioncore.e7-live-replay-run-report/v1"
|
||||
PIPELINE_ID = "bounded-latest-wins-e6-world-state-replay/v1"
|
||||
_EXECUTION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{2,95}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayFrame:
|
||||
ordinal: int
|
||||
frame: dict[str, Any]
|
||||
cloud_map: npt.NDArray[np.float32]
|
||||
position_map_xyz: tuple[float, float, float] | None
|
||||
orientation_map_from_lidar_xyzw: tuple[float, float, float, float] | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueueEnvelope:
|
||||
replay_frame: ReplayFrame
|
||||
scheduled_monotonic: float
|
||||
enqueued_monotonic: float
|
||||
producer_lag_ms: float
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job", type=Path, required=True)
|
||||
parser.add_argument("--tracking", type=Path, required=True)
|
||||
parser.add_argument("--semantic", type=Path, required=True)
|
||||
parser.add_argument("--e6-result", type=Path, required=True)
|
||||
parser.add_argument("--session", type=Path, required=True)
|
||||
parser.add_argument("--profile", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
parser.add_argument("--execution-id", 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()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_profile(path: Path) -> tuple[dict[str, Any], str]:
|
||||
resolved = path.resolve(strict=True)
|
||||
profile = json.loads(resolved.read_text(encoding="utf-8"))
|
||||
if not isinstance(profile, dict) or profile.get("schema_version") != PROFILE_SCHEMA:
|
||||
raise RuntimeError("LAB E7 profile schema changed")
|
||||
selection = profile.get("selection")
|
||||
replay = profile.get("replay")
|
||||
health = profile.get("health")
|
||||
world = profile.get("world_state")
|
||||
presentation = profile.get("presentation")
|
||||
acceptance = profile.get("acceptance")
|
||||
if not all(
|
||||
isinstance(value, dict)
|
||||
for value in (selection, replay, health, world, presentation, acceptance)
|
||||
):
|
||||
raise RuntimeError("LAB E7 profile sections are invalid")
|
||||
mode = profile.get("mode")
|
||||
if mode not in {"pilot", "qualification", "overload-negative-control"}:
|
||||
raise RuntimeError("LAB E7 mode is invalid")
|
||||
start = selection.get("start_frame_index")
|
||||
count = selection.get("frame_count")
|
||||
capacity = replay.get("queue_capacity")
|
||||
speed = replay.get("speed")
|
||||
delay = replay.get("consumer_delay_ms")
|
||||
stale = health.get("stale_after_ms")
|
||||
unavailable = health.get("unavailable_after_ms")
|
||||
if (
|
||||
not isinstance(start, int)
|
||||
or start < 0
|
||||
or not isinstance(count, int)
|
||||
or count < 2
|
||||
or not isinstance(capacity, int)
|
||||
or not 1 <= capacity <= 8
|
||||
or not isinstance(speed, int | float)
|
||||
or not 0.1 <= float(speed) <= 10.0
|
||||
or not isinstance(delay, int | float)
|
||||
or not 0 <= float(delay) <= 5000
|
||||
or not isinstance(stale, int | float)
|
||||
or not isinstance(unavailable, int | float)
|
||||
or not 0 < float(stale) < float(unavailable)
|
||||
):
|
||||
raise RuntimeError("LAB E7 scheduling contract is invalid")
|
||||
clearance = world.get("clearance")
|
||||
if not isinstance(clearance, dict):
|
||||
raise RuntimeError("LAB E7 clearance contract is invalid")
|
||||
sectors = clearance.get("sector_count")
|
||||
if not isinstance(sectors, int) or not 12 <= sectors <= 360:
|
||||
raise RuntimeError("LAB E7 clearance sector count is invalid")
|
||||
return profile, _sha256(resolved)
|
||||
|
||||
|
||||
def _load_e6_runner() -> ModuleType:
|
||||
path = Path(__file__).with_name("fuse_e6_tracking_lidar.py").resolve(strict=True)
|
||||
spec = importlib.util.spec_from_file_location("missioncore_e6_replay_adapter", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("LAB E6 replay adapter cannot be loaded")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError("LAB E7 input JSONL row is invalid")
|
||||
rows.append(value)
|
||||
return rows
|
||||
|
||||
|
||||
def _artifact_path(artifacts: Sequence[Mapping[str, Any]], kind: str, root: Path) -> Path:
|
||||
matches = [item for item in artifacts if item.get("kind") == kind]
|
||||
if len(matches) != 1 or not isinstance(matches[0].get("path"), str):
|
||||
raise RuntimeError(f"LAB E7 upstream artifact is missing: {kind}")
|
||||
return root / str(matches[0]["path"])
|
||||
|
||||
|
||||
def _prepare_frames(
|
||||
*,
|
||||
e6: Any,
|
||||
session: Path,
|
||||
selection_start: int,
|
||||
selection_count: int,
|
||||
) -> list[ReplayFrame]:
|
||||
if selection_start + selection_count > e6.frame_count:
|
||||
raise RuntimeError("LAB E7 selection exceeds the E6 frame range")
|
||||
runner = _load_e6_runner()
|
||||
frames_path = _artifact_path(
|
||||
e6.artifacts,
|
||||
"tracked-lidar-frame-metadata",
|
||||
e6.result_root,
|
||||
)
|
||||
frame_rows = _read_jsonl(frames_path)
|
||||
arrays_path = _artifact_path(e6.artifacts, "tracked-lidar-arrays", e6.result_root)
|
||||
tracking_frames = runner._read_tracking_frames(
|
||||
e6.tracking.artifact("tracking-frame-metadata").path,
|
||||
e6.tracking.frame_count,
|
||||
e6.tracking.source_start_frame_index,
|
||||
)
|
||||
origin = read_capture_clock_origin(
|
||||
session / "captures" / "mqtt_live" / "mqtt.timeline.origin.json"
|
||||
)
|
||||
epoch_root = (
|
||||
e6.job.job_root
|
||||
/ "input"
|
||||
/ "camera"
|
||||
/ e6.job.source_id
|
||||
/ f"epoch-{e6.job.codec_epoch}"
|
||||
)
|
||||
anchors = runner._camera_anchors(
|
||||
epoch_root / "index.jsonl",
|
||||
tracking_frames,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
)
|
||||
temporal = json.loads(
|
||||
(e6.result_root / "run-report.json").read_text(encoding="utf-8")
|
||||
)["identity"]["configuration"]["temporal"]
|
||||
samples = list(
|
||||
runner._lidar_samples(
|
||||
session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt",
|
||||
anchors,
|
||||
origin_monotonic_ns=origin.started_monotonic_ns,
|
||||
maximum_lidar_camera_delta_s=float(
|
||||
temporal["maximum_lidar_camera_delta_ms"]
|
||||
)
|
||||
/ 1000.0,
|
||||
maximum_pose_point_delta_s=float(temporal["maximum_pose_point_delta_ms"])
|
||||
/ 1000.0,
|
||||
)
|
||||
)
|
||||
prepared: list[ReplayFrame] = []
|
||||
with np.load(arrays_path, allow_pickle=False) as arrays:
|
||||
offsets = arrays["cloud_offsets"]
|
||||
clouds = arrays["cloud_points"]
|
||||
for ordinal in range(selection_start, selection_start + selection_count):
|
||||
frame = frame_rows[ordinal]
|
||||
sample = samples[ordinal]
|
||||
if (frame["state"] == "fused") != (sample is not None):
|
||||
raise RuntimeError("LAB E7 pose binding changed from the accepted E6 result")
|
||||
start = int(offsets[ordinal])
|
||||
end = int(offsets[ordinal + 1])
|
||||
prepared.append(
|
||||
ReplayFrame(
|
||||
ordinal=ordinal,
|
||||
frame=frame,
|
||||
cloud_map=np.asarray(clouds[start:end], dtype=np.float32).copy(),
|
||||
position_map_xyz=(
|
||||
None
|
||||
if sample is None
|
||||
else tuple(float(value) for value in sample.pose_frame.position_xyz)
|
||||
),
|
||||
orientation_map_from_lidar_xyzw=(
|
||||
None
|
||||
if sample is None
|
||||
else tuple(
|
||||
float(value)
|
||||
for value in sample.pose_frame.orientation_xyzw
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
return prepared
|
||||
|
||||
|
||||
def _transform_to_lidar(
|
||||
points_map: npt.NDArray[np.float32] | npt.NDArray[np.float64],
|
||||
frame: ReplayFrame,
|
||||
) -> npt.NDArray[np.float64]:
|
||||
if frame.position_map_xyz is None or frame.orientation_map_from_lidar_xyzw is None:
|
||||
return np.empty((0, 3), dtype=np.float64)
|
||||
return map_points_to_lidar(
|
||||
np.asarray(points_map, dtype=np.float64),
|
||||
position_map_xyz=frame.position_map_xyz,
|
||||
orientation_map_from_lidar_xyzw=frame.orientation_map_from_lidar_xyzw,
|
||||
)
|
||||
|
||||
|
||||
def _clearance(
|
||||
points_lidar: npt.NDArray[np.float64],
|
||||
profile: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
sector_count = int(profile["sector_count"])
|
||||
empty = {
|
||||
"schema": "diagnostic-polar-obstacle-clearance/v1",
|
||||
"state": "unavailable",
|
||||
"frame": "k1-lidar",
|
||||
"front_m": None,
|
||||
"observed_sector_fraction": 0.0,
|
||||
"sector_ranges_m": [None] * sector_count,
|
||||
}
|
||||
if points_lidar.size == 0:
|
||||
return empty
|
||||
finite = np.all(np.isfinite(points_lidar), axis=1)
|
||||
points = points_lidar[finite]
|
||||
if points.size == 0:
|
||||
return empty
|
||||
ground_z = float(np.percentile(points[:, 2], float(profile["ground_percentile"])))
|
||||
ranges = np.linalg.norm(points[:, :2], axis=1)
|
||||
keep = (
|
||||
(ranges >= float(profile["minimum_range_m"]))
|
||||
& (ranges <= float(profile["maximum_range_m"]))
|
||||
& (points[:, 2] >= ground_z + float(profile["minimum_height_above_ground_m"]))
|
||||
& (points[:, 2] <= ground_z + float(profile["maximum_height_above_ground_m"]))
|
||||
)
|
||||
points = points[keep]
|
||||
ranges = ranges[keep]
|
||||
if points.size == 0:
|
||||
return {**empty, "ground_z_estimate_m": ground_z}
|
||||
angles = np.arctan2(points[:, 1], points[:, 0])
|
||||
indices = np.floor((angles + math.pi) / (2.0 * math.pi) * sector_count).astype(int)
|
||||
indices = np.clip(indices, 0, sector_count - 1)
|
||||
sector_ranges = np.full(sector_count, np.inf, dtype=np.float64)
|
||||
np.minimum.at(sector_ranges, indices, ranges)
|
||||
observed = np.isfinite(sector_ranges)
|
||||
front_half = math.radians(float(profile["front_half_angle_degrees"]))
|
||||
front = ranges[np.abs(angles) <= front_half]
|
||||
return {
|
||||
"schema": "diagnostic-polar-obstacle-clearance/v1",
|
||||
"state": "observed" if np.any(observed) else "unavailable",
|
||||
"frame": "k1-lidar",
|
||||
"ground_z_estimate_m": ground_z,
|
||||
"front_m": None if front.size == 0 else float(np.min(front)),
|
||||
"observed_sector_fraction": float(np.mean(observed)),
|
||||
"sector_ranges_m": [
|
||||
None if not math.isfinite(value) else float(value) for value in sector_ranges
|
||||
],
|
||||
"safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def _percentile(values: Sequence[float], quantile: float) -> float:
|
||||
return float(np.percentile(np.asarray(values, dtype=np.float64), quantile))
|
||||
|
||||
|
||||
def _peak_rss_mib() -> float:
|
||||
value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
return float(value / (1024 * 1024) if sys.platform == "darwin" else value / 1024)
|
||||
|
||||
|
||||
def _queue_document(queue: LatestWinsQueue[Any]) -> dict[str, Any]:
|
||||
snapshot = queue.snapshot()
|
||||
return {
|
||||
"capacity": snapshot.capacity,
|
||||
"depth": snapshot.depth,
|
||||
"maximum_depth": snapshot.maximum_depth,
|
||||
"published": snapshot.published,
|
||||
"consumed": snapshot.consumed,
|
||||
"dropped_overflow": snapshot.dropped_overflow,
|
||||
"dropped_superseded": snapshot.dropped_superseded,
|
||||
"closed": snapshot.closed,
|
||||
}
|
||||
|
||||
|
||||
def _artifact(path: Path, kind: str, media_type: str, schema: str | None = None) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"kind": kind,
|
||||
"path": path.name,
|
||||
"media_type": media_type,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
if schema is not None:
|
||||
value["schema_version"] = schema
|
||||
return value
|
||||
|
||||
|
||||
def _accepted(profile: Mapping[str, Any], metrics: Mapping[str, Any]) -> tuple[bool, list[str]]:
|
||||
acceptance = profile["acceptance"]
|
||||
reasons: list[str] = []
|
||||
if metrics["queue"]["maximum_depth"] > int(acceptance["maximum_queue_depth"]):
|
||||
reasons.append("queue-depth")
|
||||
if metrics["process_peak_rss_mib"] > float(acceptance["maximum_process_rss_mib"]):
|
||||
reasons.append("process-rss")
|
||||
if profile["mode"] == "overload-negative-control":
|
||||
if metrics["drop_fraction"] < float(acceptance["minimum_drop_fraction"]):
|
||||
reasons.append("negative-control-did-not-overload")
|
||||
else:
|
||||
if metrics["delivery_rate_hz"] < float(acceptance["minimum_delivery_rate_hz"]):
|
||||
reasons.append("delivery-rate")
|
||||
if metrics["end_to_end_latency_ms"]["p95"] > float(
|
||||
acceptance["maximum_end_to_end_p95_ms"]
|
||||
):
|
||||
reasons.append("latency-p95")
|
||||
if metrics["drop_fraction"] > float(acceptance["maximum_drop_fraction"]):
|
||||
reasons.append("drop-fraction")
|
||||
return not reasons, reasons
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
if _EXECUTION_ID.fullmatch(args.execution_id) is None:
|
||||
raise RuntimeError("LAB E7 execution ID is invalid")
|
||||
profile, profile_sha256 = _read_profile(args.profile)
|
||||
session = args.session.resolve(strict=True)
|
||||
preparation_started = time.perf_counter()
|
||||
e6 = validate_tracked_fusion_qualification_result(
|
||||
args.job,
|
||||
args.tracking,
|
||||
args.semantic,
|
||||
args.e6_result,
|
||||
)
|
||||
if session.name != e6.job.session_id:
|
||||
raise RuntimeError("LAB E7 session does not match the E6 result")
|
||||
selection = profile["selection"]
|
||||
replay_frames = _prepare_frames(
|
||||
e6=e6,
|
||||
session=session,
|
||||
selection_start=int(selection["start_frame_index"]),
|
||||
selection_count=int(selection["frame_count"]),
|
||||
)
|
||||
preparation_wall = time.perf_counter() - preparation_started
|
||||
identity = {
|
||||
"schema_version": IDENTITY_SCHEMA,
|
||||
"execution_id": args.execution_id,
|
||||
"pipeline": PIPELINE_ID,
|
||||
"e6_result_id": e6.result_id,
|
||||
"job_id": e6.job.job_id,
|
||||
"input_sha256": e6.job.input_sha256,
|
||||
"session_id": e6.job.session_id,
|
||||
"source_id": e6.job.source_id,
|
||||
"calibration_sha256": e6.semantic.calibration_sha256,
|
||||
"camera_slot": e6.semantic.calibration_slot,
|
||||
"selection": selection,
|
||||
"configuration": profile,
|
||||
"profile_sha256": profile_sha256,
|
||||
"producer_sha256": _sha256(Path(__file__).resolve(strict=True)),
|
||||
"runtime_contract_sha256": _sha256(
|
||||
Path(live_perception_module.__file__).resolve(strict=True)
|
||||
),
|
||||
"result_validator_sha256": _sha256(
|
||||
Path(live_validator_module.__file__).resolve(strict=True)
|
||||
),
|
||||
"e6_adapter_sha256": _sha256(
|
||||
Path(__file__).with_name("fuse_e6_tracking_lidar.py").resolve(strict=True)
|
||||
),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
result_id = f"e7-live-replay-{identity_sha256}"
|
||||
parent = args.output_root.resolve()
|
||||
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
output = parent / result_id
|
||||
if output.exists():
|
||||
print(json.dumps({"result_id": result_id, "output": str(output), "reused": True}))
|
||||
return 0
|
||||
staging = parent / f".{result_id}.{os.getpid()}.incomplete"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
world_path = staging / "world-state.jsonl"
|
||||
telemetry_path = staging / "telemetry.jsonl"
|
||||
rrd_path = staging / "world-state.rrd"
|
||||
report_path = staging / "run-report.json"
|
||||
queue = LatestWinsQueue[QueueEnvelope](int(profile["replay"]["queue_capacity"]))
|
||||
producer_error: list[BaseException] = []
|
||||
recording: rr.RecordingStream | None = None
|
||||
try:
|
||||
recording = rr.RecordingStream("nodedc_mission_core_e7", recording_id=result_id)
|
||||
recording.set_sinks(rr.FileSink(rrd_path, write_footer=True))
|
||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
recording.log(
|
||||
"/contract",
|
||||
rr.TextDocument(
|
||||
"LAB E7 replay-as-live: bounded latest-wins derived queue, explicit age and "
|
||||
"degradation, E6 diagnostic objects, no vehicle-control authority."
|
||||
),
|
||||
static=True,
|
||||
)
|
||||
replay_speed = float(profile["replay"]["speed"])
|
||||
first_session = float(replay_frames[0].frame["session_seconds"])
|
||||
replay_started = time.perf_counter() + 0.25
|
||||
|
||||
def produce() -> None:
|
||||
try:
|
||||
for replay_frame in replay_frames:
|
||||
offset = (
|
||||
float(replay_frame.frame["session_seconds"]) - first_session
|
||||
) / replay_speed
|
||||
deadline = replay_started + offset
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
enqueued = time.perf_counter()
|
||||
queue.publish(
|
||||
QueueEnvelope(
|
||||
replay_frame=replay_frame,
|
||||
scheduled_monotonic=deadline,
|
||||
enqueued_monotonic=enqueued,
|
||||
producer_lag_ms=max(0.0, (enqueued - deadline) * 1000.0),
|
||||
)
|
||||
)
|
||||
except BaseException as exc: # noqa: BLE001 - forwarded to main thread
|
||||
producer_error.append(exc)
|
||||
finally:
|
||||
queue.close()
|
||||
|
||||
producer = threading.Thread(target=produce, name="e7-replay-producer", daemon=True)
|
||||
producer.start()
|
||||
projector = WorldStateProjector(
|
||||
velocity_history_limit_s=float(
|
||||
profile["world_state"]["velocity_history_limit_s"]
|
||||
)
|
||||
)
|
||||
health_counts: Counter[str] = Counter()
|
||||
end_to_end: list[float] = []
|
||||
queue_wait: list[float] = []
|
||||
processing: list[float] = []
|
||||
rerun_latency: list[float] = []
|
||||
producer_lag: list[float] = []
|
||||
published_indices: list[int] = []
|
||||
consumer_delay = float(profile["replay"]["consumer_delay_ms"]) / 1000.0
|
||||
with world_path.open("w", encoding="utf-8") as world_stream, telemetry_path.open(
|
||||
"w", encoding="utf-8"
|
||||
) as telemetry_stream:
|
||||
while True:
|
||||
envelope = queue.take_next(timeout=0.5)
|
||||
if envelope is None:
|
||||
if queue.snapshot().closed:
|
||||
break
|
||||
continue
|
||||
dequeued = time.perf_counter()
|
||||
if consumer_delay > 0:
|
||||
time.sleep(consumer_delay)
|
||||
stage_started = time.perf_counter()
|
||||
replay_frame = envelope.replay_frame
|
||||
cloud_lidar = _transform_to_lidar(replay_frame.cloud_map, replay_frame)
|
||||
accepted = [
|
||||
item
|
||||
for item in replay_frame.frame.get("objects", [])
|
||||
if str(item.get("cuboid_status", "")).startswith("accepted-")
|
||||
]
|
||||
centers_map = np.asarray(
|
||||
[item["cuboid_center_map"] for item in accepted], dtype=np.float64
|
||||
).reshape((-1, 3))
|
||||
centers_lidar = _transform_to_lidar(centers_map, replay_frame)
|
||||
lidar_positions = {
|
||||
int(item["track_id"]): centers_lidar[index]
|
||||
for index, item in enumerate(accepted)
|
||||
}
|
||||
world = projector.project(
|
||||
frame=replay_frame.frame,
|
||||
lidar_positions=lidar_positions,
|
||||
clearance=_clearance(
|
||||
cloud_lidar,
|
||||
profile["world_state"]["clearance"],
|
||||
),
|
||||
)
|
||||
projection_finished = time.perf_counter()
|
||||
relative_seconds = (
|
||||
float(replay_frame.frame["session_seconds"]) - first_session
|
||||
)
|
||||
recording.set_time(
|
||||
"replay_time",
|
||||
duration=np.timedelta64(round(relative_seconds * 1e9), "ns"),
|
||||
)
|
||||
if bool(profile["presentation"]["log_point_cloud"]) and replay_frame.cloud_map.size:
|
||||
recording.log(
|
||||
"/world/lidar",
|
||||
rr.Points3D(
|
||||
replay_frame.cloud_map,
|
||||
colors=np.tile(
|
||||
np.asarray([[145, 145, 145]], dtype=np.uint8),
|
||||
(replay_frame.cloud_map.shape[0], 1),
|
||||
),
|
||||
radii=rr.Radius.ui_points(
|
||||
float(profile["presentation"]["point_radius_ui"])
|
||||
),
|
||||
),
|
||||
)
|
||||
elif not replay_frame.cloud_map.size:
|
||||
recording.log("/world/lidar", rr.Clear(recursive=False))
|
||||
if accepted:
|
||||
colors = []
|
||||
labels = []
|
||||
for item in accepted:
|
||||
group = str(item["association_group"])
|
||||
colors.append(
|
||||
{
|
||||
"person": [255, 70, 170, 88],
|
||||
"vehicle": [130, 90, 255, 88],
|
||||
"bicycle": [80, 220, 255, 88],
|
||||
"motorcycle": [255, 180, 65, 88],
|
||||
}.get(group, [220, 220, 220, 88])
|
||||
)
|
||||
labels.append(
|
||||
f"{group} #{item['track_id']} · "
|
||||
f"{float(item['distance_smoothed_m']):.1f} m"
|
||||
)
|
||||
recording.log(
|
||||
"/world/perception/boxes3d",
|
||||
rr.Boxes3D(
|
||||
centers=centers_map.astype(np.float32),
|
||||
half_sizes=np.asarray(
|
||||
[item["cuboid_half_size"] for item in accepted],
|
||||
dtype=np.float32,
|
||||
),
|
||||
quaternions=np.asarray(
|
||||
[item["cuboid_quaternion_xyzw"] for item in accepted],
|
||||
dtype=np.float32,
|
||||
),
|
||||
colors=np.asarray(colors, dtype=np.uint8),
|
||||
labels=labels,
|
||||
fill_mode=FillMode.Solid,
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
recording.log("/world/perception/boxes3d", rr.Clear(recursive=False))
|
||||
rerun_finished = time.perf_counter()
|
||||
age_ms = (rerun_finished - envelope.scheduled_monotonic) * 1000.0
|
||||
health, reasons = classify_health(
|
||||
source_available=True,
|
||||
fusion_state=str(replay_frame.frame["state"]),
|
||||
result_age_ms=age_ms,
|
||||
stale_after_ms=float(profile["health"]["stale_after_ms"]),
|
||||
unavailable_after_ms=float(profile["health"]["unavailable_after_ms"]),
|
||||
)
|
||||
health_counts[health] += 1
|
||||
world["delivery"] = {
|
||||
"health": health,
|
||||
"reasons": list(reasons),
|
||||
"result_age_ms": age_ms,
|
||||
"queue_policy": "bounded-latest-wins",
|
||||
}
|
||||
world_stream.write(json.dumps(world, ensure_ascii=False, allow_nan=False) + "\n")
|
||||
published = time.perf_counter()
|
||||
latency_ms = (published - envelope.scheduled_monotonic) * 1000.0
|
||||
recording.log("/telemetry/end_to_end_latency_ms", rr.Scalars([latency_ms]))
|
||||
recording.log("/telemetry/queue_depth", rr.Scalars([queue.snapshot().depth]))
|
||||
recording.log(
|
||||
"/telemetry/health_code",
|
||||
rr.Scalars(
|
||||
[{"healthy": 0, "degraded": 1, "stale": 2, "unavailable": 3}[health]]
|
||||
),
|
||||
)
|
||||
telemetry = {
|
||||
"schema_version": TELEMETRY_SCHEMA,
|
||||
"frame_index": int(replay_frame.frame["frame_index"]),
|
||||
"source_frame_index": int(replay_frame.frame["source_frame_index"]),
|
||||
"session_seconds": float(replay_frame.frame["session_seconds"]),
|
||||
"producer_lag_ms": envelope.producer_lag_ms,
|
||||
"queue_wait_ms": (dequeued - envelope.enqueued_monotonic) * 1000.0,
|
||||
"world_projection_ms": (projection_finished - stage_started) * 1000.0,
|
||||
"rerun_publish_ms": (rerun_finished - projection_finished) * 1000.0,
|
||||
"end_to_end_latency_ms": latency_ms,
|
||||
"health": health,
|
||||
"queue": _queue_document(queue),
|
||||
}
|
||||
telemetry_stream.write(
|
||||
json.dumps(telemetry, ensure_ascii=False, allow_nan=False) + "\n"
|
||||
)
|
||||
end_to_end.append(latency_ms)
|
||||
queue_wait.append(telemetry["queue_wait_ms"])
|
||||
processing.append(telemetry["world_projection_ms"])
|
||||
rerun_latency.append(telemetry["rerun_publish_ms"])
|
||||
producer_lag.append(envelope.producer_lag_ms)
|
||||
published_indices.append(int(replay_frame.frame["frame_index"]))
|
||||
producer.join(timeout=5)
|
||||
if producer.is_alive():
|
||||
raise RuntimeError("LAB E7 replay producer did not stop")
|
||||
if producer_error:
|
||||
raise RuntimeError("LAB E7 replay producer failed") from producer_error[0]
|
||||
if recording is not None:
|
||||
recording.flush()
|
||||
recording.disconnect()
|
||||
recording = None
|
||||
finished = time.perf_counter()
|
||||
queue_metrics = queue.snapshot()
|
||||
replay_span = (
|
||||
float(replay_frames[-1].frame["session_seconds"])
|
||||
- float(replay_frames[0].frame["session_seconds"])
|
||||
) / replay_speed
|
||||
replay_wall = finished - replay_started
|
||||
attempted = len(replay_frames)
|
||||
processed = len(published_indices)
|
||||
metrics = {
|
||||
"frames_attempted": attempted,
|
||||
"frames_processed": processed,
|
||||
"frames_dropped": queue_metrics.dropped_total,
|
||||
"drop_fraction": queue_metrics.dropped_total / attempted,
|
||||
"delivery_rate_hz": processed / replay_span,
|
||||
"replay_span_seconds": replay_span,
|
||||
"replay_wall_seconds": replay_wall,
|
||||
"preparation_wall_seconds": preparation_wall,
|
||||
"process_peak_rss_mib": _peak_rss_mib(),
|
||||
"health_counts": dict(health_counts),
|
||||
"queue": {
|
||||
"capacity": queue_metrics.capacity,
|
||||
"maximum_depth": queue_metrics.maximum_depth,
|
||||
"final_depth": queue_metrics.depth,
|
||||
"published": queue_metrics.published,
|
||||
"consumed": queue_metrics.consumed,
|
||||
"dropped_overflow": queue_metrics.dropped_overflow,
|
||||
"dropped_superseded": queue_metrics.dropped_superseded,
|
||||
},
|
||||
"end_to_end_latency_ms": {
|
||||
"mean": float(np.mean(end_to_end)),
|
||||
"p50": _percentile(end_to_end, 50),
|
||||
"p95": _percentile(end_to_end, 95),
|
||||
"max": max(end_to_end),
|
||||
},
|
||||
"queue_wait_ms": {
|
||||
"mean": float(np.mean(queue_wait)),
|
||||
"p95": _percentile(queue_wait, 95),
|
||||
"max": max(queue_wait),
|
||||
},
|
||||
"world_projection_ms": {
|
||||
"mean": float(np.mean(processing)),
|
||||
"p95": _percentile(processing, 95),
|
||||
"max": max(processing),
|
||||
},
|
||||
"rerun_publish_ms": {
|
||||
"mean": float(np.mean(rerun_latency)),
|
||||
"p95": _percentile(rerun_latency, 95),
|
||||
"max": max(rerun_latency),
|
||||
},
|
||||
"producer_lag_ms": {
|
||||
"mean": float(np.mean(producer_lag)),
|
||||
"p95": _percentile(producer_lag, 95),
|
||||
"max": max(producer_lag),
|
||||
},
|
||||
"published_frame_index_start": min(published_indices),
|
||||
"published_frame_index_end": max(published_indices),
|
||||
}
|
||||
accepted, rejection_reasons = _accepted(profile, metrics)
|
||||
report = {
|
||||
"schema_version": REPORT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"created_at_utc": datetime.now(UTC).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"identity": identity,
|
||||
"acceptance": {
|
||||
"accepted": accepted,
|
||||
"mode": profile["mode"],
|
||||
"rejection_reasons": rejection_reasons,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"scope": "replay-scheduler-and-world-state-not-live-inference",
|
||||
},
|
||||
"metrics": metrics,
|
||||
}
|
||||
report_path.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
artifacts = [
|
||||
_artifact(
|
||||
world_path,
|
||||
"live-world-state",
|
||||
"application/x-ndjson",
|
||||
"missioncore.live-perception-world-state/v1",
|
||||
),
|
||||
_artifact(
|
||||
telemetry_path,
|
||||
"live-telemetry",
|
||||
"application/x-ndjson",
|
||||
TELEMETRY_SCHEMA,
|
||||
),
|
||||
_artifact(rrd_path, "live-rerun", "application/vnd.rerun.rrd"),
|
||||
_artifact(report_path, "live-run-report", "application/json", REPORT_SCHEMA),
|
||||
]
|
||||
result = {
|
||||
"schema_version": RESULT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"ground_truth": False,
|
||||
"publication_scope": "replay-qualification-only",
|
||||
"acceptance_state": "accepted" if accepted else "rejected",
|
||||
"metrics": metrics,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
(staging / "result.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
staging.rename(output)
|
||||
validated = validate_live_replay_qualification_result(
|
||||
args.job,
|
||||
args.tracking,
|
||||
args.semantic,
|
||||
args.e6_result,
|
||||
output,
|
||||
)
|
||||
if validated.result_id != result_id or validated.accepted != accepted:
|
||||
raise RuntimeError("LAB E7 published result failed identity validation")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result_id,
|
||||
"output": str(output),
|
||||
"accepted": accepted,
|
||||
"metrics": metrics,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0 if accepted else 2
|
||||
except BaseException:
|
||||
if recording is not None:
|
||||
recording.disconnect()
|
||||
queue.close()
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateRange(1, 1000)] [int]$FreeGiBFloor = 360,
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3@sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794",
|
||||
[string]$RuntimeName = "perception-e15-media-pyav180-lz445-v1"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
|
||||
function Assert-FreeSpace([string]$Phase) {
|
||||
$free = [int64](Get-PSDrive -Name D).Free
|
||||
$floor = [int64]$FreeGiBFloor * 1GB
|
||||
Write-Host (
|
||||
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3}" -f
|
||||
$Phase, $free, [math]::Round($free / 1GB, 3), $FreeGiBFloor
|
||||
)
|
||||
if ($free -lt ($floor + 2GB)) {
|
||||
throw "D: lacks the guarded LAB E15 media-runtime reserve during $Phase"
|
||||
}
|
||||
return $free
|
||||
}
|
||||
|
||||
function Get-PayloadDigest([string]$Root) {
|
||||
$resolved = Resolve-DDirectory $Root "E15 media runtime"
|
||||
$lines = @(
|
||||
Get-ChildItem -LiteralPath $resolved -Recurse -File -Force |
|
||||
Where-Object { $_.Name -ne "manifest.json" } |
|
||||
Sort-Object FullName |
|
||||
ForEach-Object {
|
||||
$relative = $_.FullName.Substring($resolved.Length).TrimStart("\").Replace("\", "/")
|
||||
$hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"{0}`t{1}`t{2}" -f $relative, $_.Length, $hash
|
||||
}
|
||||
)
|
||||
if ($lines.Count -lt 4) { throw "E15 media runtime payload is incomplete" }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes(($lines -join "`n") + "`n")
|
||||
$hasher = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString($hasher.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant()
|
||||
}
|
||||
finally { $hasher.Dispose() }
|
||||
}
|
||||
|
||||
function Assert-Runtime([string]$Path) {
|
||||
$root = Resolve-DDirectory $Path "E15 media runtime"
|
||||
$manifestPath = Join-Path $root "manifest.json"
|
||||
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
|
||||
throw "E15 media runtime manifest is missing"
|
||||
}
|
||||
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$manifest.schema_version -ne "missioncore.e15-media-runtime/v1" -or
|
||||
$manifest.runtime_name -ne $RuntimeName -or
|
||||
$manifest.container_image -ne $ContainerImage -or
|
||||
$manifest.packages.av -ne "18.0.0" -or
|
||||
$manifest.packages.lz4 -ne "4.4.5" -or
|
||||
$manifest.payload_sha256 -ne (Get-PayloadDigest $root)
|
||||
) { throw "E15 media runtime identity changed" }
|
||||
|
||||
$dockerRoot = Convert-ToDockerPath $root
|
||||
$verifyCode = "import av,lz4.version;print(av.__version__);print(lz4.version.version)"
|
||||
$verifyOutput = @(& docker run --rm --network none --read-only `
|
||||
--security-opt "no-new-privileges:true" --cap-drop ALL --pids-limit 64 `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=64m" `
|
||||
-e "PYTHONPATH=/opt/media" -e "PYTHONDONTWRITEBYTECODE=1" `
|
||||
-v ("{0}:/opt/media:ro" -f $dockerRoot) `
|
||||
--entrypoint python3 $ContainerImage -c $verifyCode)
|
||||
Assert-LastExitCode "E15 media runtime verification"
|
||||
if ($verifyOutput.Count -ne 2 -or $verifyOutput[0] -ne "18.0.0" -or $verifyOutput[1] -ne "4.4.5") {
|
||||
throw "E15 media runtime package versions changed"
|
||||
}
|
||||
Write-Host "MEDIA_RUNTIME_OK pyav=18.0.0 lz4=4.4.5"
|
||||
return $root
|
||||
}
|
||||
|
||||
$runtime = Resolve-DDirectory $RuntimeRoot "Runtime root"
|
||||
$derived = Resolve-DDirectory (Join-Path $runtime "derived") "Runtime derived root"
|
||||
$destination = Join-Path $derived $RuntimeName
|
||||
$freeBefore = Assert-FreeSpace "media-runtime-preflight"
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Pinned container image inspection"
|
||||
|
||||
if (Test-Path -LiteralPath $destination) {
|
||||
$resolved = Assert-Runtime $destination
|
||||
Write-Output "STATE=existing-verified"
|
||||
Write-Output ("MEDIA_RUNTIME_ROOT={0}" -f $resolved)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_AFTER={0}" -f ([int64](Get-PSDrive -Name D).Free))
|
||||
return
|
||||
}
|
||||
|
||||
$token = [Guid]::NewGuid().ToString("N")
|
||||
$staging = Join-Path $derived (".{0}-{1}.tmp" -f $RuntimeName, $token)
|
||||
$null = New-Item -ItemType Directory -Path $staging
|
||||
$completed = $false
|
||||
try {
|
||||
$dockerStaging = Convert-ToDockerPath $staging
|
||||
Write-Output "PHASE=media-runtime-install-start"
|
||||
& docker run --rm --network bridge --read-only `
|
||||
--security-opt "no-new-privileges:true" --cap-drop ALL --pids-limit 128 `
|
||||
--tmpfs "/tmp:rw,nosuid,size=1g" `
|
||||
-e "PIP_DISABLE_PIP_VERSION_CHECK=1" -e "PYTHONDONTWRITEBYTECODE=1" `
|
||||
-v ("{0}:/target:rw" -f $dockerStaging) `
|
||||
--entrypoint python3 $ContainerImage -m pip install `
|
||||
--no-cache-dir --only-binary ":all:" --target /target `
|
||||
"av==18.0.0" "lz4==4.4.5"
|
||||
Assert-LastExitCode "E15 media runtime installation"
|
||||
|
||||
$payloadSha256 = Get-PayloadDigest $staging
|
||||
$manifest = [ordered]@{
|
||||
schema_version = "missioncore.e15-media-runtime/v1"
|
||||
runtime_name = $RuntimeName
|
||||
created_at_utc = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
|
||||
container_image = $ContainerImage
|
||||
packages = [ordered]@{ av = "18.0.0"; lz4 = "4.4.5" }
|
||||
payload_sha256 = $payloadSha256
|
||||
storage_scope = "D-only-immutable-runtime"
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath (Join-Path $staging "manifest.json") -Encoding utf8
|
||||
$null = Assert-Runtime $staging
|
||||
Move-Item -LiteralPath $staging -Destination $destination
|
||||
$completed = $true
|
||||
$resolved = Assert-Runtime $destination
|
||||
$freeAfter = Assert-FreeSpace "media-runtime-published"
|
||||
Write-Output "STATE=created-verified"
|
||||
Write-Output ("MEDIA_RUNTIME_ROOT={0}" -f $resolved)
|
||||
Write-Output ("PAYLOAD_SHA256={0}" -f $payloadSha256)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_AFTER={0}" -f $freeAfter)
|
||||
}
|
||||
finally {
|
||||
if (-not $completed -and (Test-Path -LiteralPath $staging)) {
|
||||
Remove-Item -LiteralPath $staging -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string]$JobRoot,
|
||||
[Parameter(Mandatory = $true)] [string]$RunnerPath,
|
||||
[Parameter(Mandatory = $true)] [string]$ProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$DetectorProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$SemanticProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$ValidFovRoot,
|
||||
[Parameter(Mandatory = $true)] [string]$LidarPackRoot,
|
||||
[ValidateRange(0, 1000000)] [int]$StartFrame = 1000,
|
||||
[ValidateRange(0, 1000000)] [int]$EndFrame = 1600,
|
||||
[ValidateRange(1, 1000)] [int]$FreeGiBFloor = 360,
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
[string]$ModelRoot = "D:\NDC_MISSIONCORE\runtime\models\yolox_s",
|
||||
[string]$TritonContainer = "mission-core-triton",
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Resolve-DFile([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a regular D: file"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
function Get-DFreeBytes { return [int64](Get-PSDrive -Name D).Free }
|
||||
function Assert-FreeSpace([string]$Phase, [int64]$RequiredAdditionalBytes = 0) {
|
||||
$free = Get-DFreeBytes
|
||||
$floor = [int64]$FreeGiBFloor * 1GB
|
||||
Write-Host (
|
||||
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3} REQUIRED_ADDITIONAL_GIB={4}" -f
|
||||
$Phase, $free, [math]::Round($free / 1GB, 3), $FreeGiBFloor,
|
||||
[math]::Round($RequiredAdditionalBytes / 1GB, 3)
|
||||
)
|
||||
if ($free -lt ($floor + $RequiredAdditionalBytes)) {
|
||||
throw "D: lacks the guarded LAB E10 reserve during $Phase"
|
||||
}
|
||||
return $free
|
||||
}
|
||||
|
||||
function Test-TritonModelReady {
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 `
|
||||
-Uri "http://127.0.0.1:8000/v2/models/yolox_s/ready"
|
||||
return $response.StatusCode -eq 200
|
||||
}
|
||||
catch { return $false }
|
||||
}
|
||||
|
||||
$jobDirectory = Resolve-DDirectory $JobRoot "Job root"
|
||||
$runner = Resolve-DFile $RunnerPath "LAB E10 runner"
|
||||
$profile = Resolve-DFile $ProfilePath "LAB E10 profile"
|
||||
$detectorProfile = Resolve-DFile $DetectorProfilePath "Detector profile"
|
||||
$semanticProfile = Resolve-DFile $SemanticProfilePath "Semantic profile"
|
||||
$validFov = Resolve-DDirectory $ValidFovRoot "Valid-FOV root"
|
||||
$lidarPack = Resolve-DDirectory $LidarPackRoot "LiDAR replay pack"
|
||||
$runtime = Resolve-DDirectory $RuntimeRoot "Runtime root"
|
||||
$model = Resolve-DDirectory $ModelRoot "YOLOX model root"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
foreach ($path in @($profile, $detectorProfile, $semanticProfile)) {
|
||||
if ((Split-Path $path -Parent) -ne $runnerRoot) { throw "Runner and profiles must share one mount" }
|
||||
}
|
||||
foreach ($dependency in @(
|
||||
"e10_fusion_runtime.py",
|
||||
"run_e9_multirate_perception.py",
|
||||
"run_e8_realtime_tracking.py",
|
||||
"run_e5_instance_tracking.py",
|
||||
"run_e4_full_session_segmentation.py",
|
||||
"run_recorded_perception_epoch.py",
|
||||
"run_e3_rectified_segmentation.py",
|
||||
"run_evaluation_prelabels.py"
|
||||
)) {
|
||||
$null = Resolve-DFile (Join-Path $runnerRoot $dependency) "LAB E10 dependency"
|
||||
}
|
||||
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if ($job.schema_version -ne "missioncore.compute-job/v1" -or $job.job_id -ne (Split-Path $jobDirectory -Leaf)) {
|
||||
throw "Compute job manifest is incompatible"
|
||||
}
|
||||
$sourceId = [string]$job.input.source_id
|
||||
$epoch = [int]$job.input.codec_epoch
|
||||
$fullFrameCount = [int]$job.input.segment_count
|
||||
$clipFrameCount = $EndFrame - $StartFrame + 1
|
||||
$timelineStart = [double]$job.input.timeline.start_seconds
|
||||
if ($sourceId -ne "sensor.camera.right" -or $StartFrame -lt 0 -or $EndFrame -lt $StartFrame -or $EndFrame -ge $fullFrameCount -or $clipFrameCount -lt 2) {
|
||||
throw "LAB E10 clip escapes the camera job"
|
||||
}
|
||||
$lidarManifest = Get-Content -LiteralPath (Join-Path $lidarPack "manifest.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$lidarManifest.schema_version -ne "missioncore.e10-lidar-replay-pack/v1" -or
|
||||
$lidarManifest.identity.job_id -ne $job.job_id -or
|
||||
[int]$lidarManifest.identity.source_start_frame_index -ne $StartFrame -or
|
||||
[int]$lidarManifest.identity.source_end_frame_index -ne $EndFrame -or
|
||||
[int]$lidarManifest.identity.frame_count -ne $clipFrameCount
|
||||
) { throw "LAB E10 LiDAR pack differs from the selected camera clip" }
|
||||
Write-Output ("PHASE=inputs-validated JOB={0} CLIP={1}-{2} FRAMES={3} LIDAR_PACK={4}" -f $job.job_id, $StartFrame, $EndFrame, $clipFrameCount, $lidarManifest.pack_id)
|
||||
|
||||
$epochRoot = Resolve-DDirectory (Join-Path $jobDirectory ("input\camera\{0}\epoch-{1}" -f $sourceId, $epoch)) "Camera epoch"
|
||||
$initPath = Resolve-DFile (Join-Path $epochRoot "init.mp4") "Camera init"
|
||||
$segmentsRoot = Resolve-DDirectory (Join-Path $epochRoot "segments") "Camera segments"
|
||||
$derivedRoot = Join-Path $runtime "derived"
|
||||
$tmpRoot = Join-Path $runtime "tmp"
|
||||
$cacheRoot = Resolve-DDirectory (Join-Path $runtime "cache\perception-e3-models-v1") "EoMT cache"
|
||||
$e3Environment = Resolve-DDirectory (Join-Path $derivedRoot "perception-e3-opencv413092-v1") "E3 environment"
|
||||
$torchEnvironment = Resolve-DDirectory (Join-Path $derivedRoot "perception-p0-env-v1") "Torch environment"
|
||||
$transformersEnvironment = Resolve-DDirectory (Join-Path $derivedRoot "perception-p0-transformers4576-v1") "Transformers environment"
|
||||
|
||||
$pixelWorkingSet = [int64]$clipFrameCount * 800 * 600 * 3
|
||||
$partialStreamReserve = [int64][math]::Ceiling(([int64]$job.input.byte_length * ($EndFrame + 1) / $fullFrameCount) * 1.2)
|
||||
$workingSetReserve = [int64][math]::Ceiling($pixelWorkingSet * 1.4) + $partialStreamReserve + 3GB
|
||||
$freeBytesBefore = Assert-FreeSpace "preflight" $workingSetReserve
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Existing container image inspection"
|
||||
$tritonState = docker inspect --format "{{.State.Running}}" $TritonContainer
|
||||
Assert-LastExitCode "Triton container inspection"
|
||||
if ($tritonState.Trim().ToLowerInvariant() -ne "true") { throw "LAB E10 requires the existing Triton container" }
|
||||
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$profileName = Split-Path $profile -Leaf
|
||||
$detectorProfileName = Split-Path $detectorProfile -Leaf
|
||||
$semanticProfileName = Split-Path $semanticProfile -Leaf
|
||||
$lidarMount = "/" + (Split-Path $lidarPack -Leaf)
|
||||
$orchestratorSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$commonMounts = @(
|
||||
"-e", "PYTHONPATH=/runner:/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $lidarPack) + (":{0}:ro" -f $lidarMount)),
|
||||
"-v", ((Convert-ToDockerPath $model) + ":/model:ro"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:ro"),
|
||||
"-v", ((Convert-ToDockerPath $e3Environment) + ":/environment:ro"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro")
|
||||
)
|
||||
$commonRunnerArgs = @(
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--detector-profile", ("/runner/{0}" -f $detectorProfileName),
|
||||
"--semantic-profile", ("/runner/{0}" -f $semanticProfileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--model-root", "/model",
|
||||
"--cache", "/cache",
|
||||
"--environment", "/environment",
|
||||
"--lidar-pack", $lidarMount
|
||||
)
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g"
|
||||
) + $commonMounts + @(
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "preflight"
|
||||
) + $commonRunnerArgs
|
||||
Write-Output "PHASE=e10-preflight-start"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "LAB E10 preflight"
|
||||
Write-Output "PHASE=e10-preflight-complete"
|
||||
|
||||
$modelWasReady = Test-TritonModelReady
|
||||
$loadedByRun = $false
|
||||
if (-not $modelWasReady) {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 -Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/load" -Method Post -ContentType "application/json" -Body "{}" *> $null
|
||||
$loadedByRun = $true
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
while (-not (Test-TritonModelReady)) {
|
||||
if ([DateTime]::UtcNow -ge $deadline) { throw "YOLOX-S did not become ready" }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
Write-Output ("PHASE=e10-model-ready LOADED_BY_RUN={0}" -f $loadedByRun)
|
||||
|
||||
$token = [Guid]::NewGuid().ToString("N")
|
||||
$workRoot = Join-Path $tmpRoot ("{0}-e10-{1}" -f $job.job_id, $token)
|
||||
$framesRoot = Join-Path $workRoot "frames"
|
||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||
$ptsPath = Join-Path $workRoot "pts.json"
|
||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-e10-{1}.publish" -f $job.job_id, $token)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
$completed = $false
|
||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
try {
|
||||
$stream = [IO.File]::Open($streamPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try {
|
||||
$input = [IO.File]::OpenRead($initPath)
|
||||
try { $input.CopyTo($stream) } finally { $input.Dispose() }
|
||||
for ($sequence = 1; $sequence -le ($EndFrame + 1); $sequence++) {
|
||||
$path = Resolve-DFile (Join-Path $segmentsRoot ("{0}.m4s" -f $sequence)) "Camera segment"
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try { $input.CopyTo($stream) } finally { $input.Dispose() }
|
||||
}
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
$null = Assert-FreeSpace "post-stream-reconstruction"
|
||||
|
||||
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$selectFilter = "select='between(n,{0},{1})'" -f $StartFrame, $EndFrame
|
||||
& ffmpeg -hide_banner -loglevel fatal -i $streamPath -map 0:v:0 -vf $selectFilter -fps_mode passthrough (Join-Path $framesRoot "frame-%06d.png")
|
||||
Assert-LastExitCode "LAB E10 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 E10 timestamp probe"
|
||||
$decoded = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||
$pts = @((Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json).frames)
|
||||
if ($decoded.Count -ne $clipFrameCount -or $pts.Count -ne ($EndFrame + 1)) { throw "LAB E10 decoded frame count changed" }
|
||||
$firstEpochSeconds = [double]::Parse(([string]$pts[0].best_effort_timestamp_time).Trim(), [Globalization.CultureInfo]::InvariantCulture)
|
||||
$writer = [IO.StreamWriter]::new($timelinePath, $false, [Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
for ($local = 0; $local -lt $clipFrameCount; $local++) {
|
||||
$source = $StartFrame + $local
|
||||
$epochSeconds = [double]::Parse(([string]$pts[$source].best_effort_timestamp_time).Trim(), [Globalization.CultureInfo]::InvariantCulture) - $firstEpochSeconds
|
||||
$row = [ordered]@{
|
||||
frame_index = $local
|
||||
sequence = $local + 1
|
||||
source_frame_index = $source
|
||||
source_sequence = $source + 1
|
||||
epoch_seconds = $epochSeconds
|
||||
session_seconds = $timelineStart + $epochSeconds
|
||||
}
|
||||
$writer.WriteLine(($row | ConvertTo-Json -Compress))
|
||||
}
|
||||
$writer.Flush()
|
||||
}
|
||||
finally { $writer.Dispose() }
|
||||
$extractWatch.Stop()
|
||||
$freePostExtract = Assert-FreeSpace "post-frame-extraction"
|
||||
|
||||
$runArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", ("container:{0}" -f $TritonContainer),
|
||||
"--read-only", "--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g"
|
||||
) + $commonMounts + @(
|
||||
"-v", ((Convert-ToDockerPath $framesRoot) + ":/frames:ro"),
|
||||
"-v", ((Convert-ToDockerPath $workRoot) + ":/work:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "run"
|
||||
) + $commonRunnerArgs + @(
|
||||
"--frames", "/frames",
|
||||
"--timeline", "/work/timeline.jsonl",
|
||||
"--output", "/publish/output",
|
||||
"--triton-url", "http://127.0.0.1:8000",
|
||||
"--free-bytes-floor", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--orchestrator-sha256", $orchestratorSha256,
|
||||
"--container-image", $ContainerImage
|
||||
)
|
||||
Write-Output ("PHASE=e10-integrated-replay-start FRAMES={0}" -f $clipFrameCount)
|
||||
& docker @runArgs
|
||||
Assert-LastExitCode "LAB E10 integrated replay"
|
||||
$freePostReplay = Assert-FreeSpace "post-replay"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if ($result.schema_version -ne "missioncore.e10-integrated-perception-result/v1" -or $result.result_id -notmatch "^e10-integrated-perception-[a-f0-9]{64}$") {
|
||||
throw "LAB E10 result manifest is incompatible"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) { throw "Immutable LAB E10 result already exists" }
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
$completed = $true
|
||||
$totalWatch.Stop()
|
||||
$freeFinal = Assert-FreeSpace "post-publication"
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
Write-Output ("ACCEPTANCE_STATE={0}" -f $result.acceptance_state)
|
||||
Write-Output ("PREPARATION_SECONDS={0}" -f $extractWatch.Elapsed.TotalSeconds)
|
||||
Write-Output ("ORCHESTRATOR_WALL_SECONDS={0}" -f $totalWatch.Elapsed.TotalSeconds)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_EXTRACT={0}" -f $freePostExtract)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_REPLAY={0}" -f $freePostReplay)
|
||||
Write-Output ("DISK_FREE_BYTES_FINAL={0}" -f $freeFinal)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force }
|
||||
if (-not $completed -and (Test-Path -LiteralPath $publishRoot)) { Remove-Item -LiteralPath $publishRoot -Recurse -Force }
|
||||
if ($loadedByRun) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 -Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/unload" -Method Post -ContentType "application/json" -Body "{}" *> $null
|
||||
Write-Output "PHASE=e10-model-state-restored"
|
||||
}
|
||||
catch { Write-Warning "LAB E10 could not restore the prior YOLOX-S state" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$TokenStdin,
|
||||
[ValidatePattern("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")]
|
||||
[string]$RequestId = ("physical-k1-shadow-{0}" -f [Guid]::NewGuid().ToString("N")),
|
||||
[string]$PersistentContainer = "mission-core-perception-worker",
|
||||
[string]$PersistentOutputRoot = "D:\NDC_MISSIONCORE\runtime\derived\.perception-persistent-publish",
|
||||
[ValidateRange(1024, 65535)] [int]$PersistentPort = 18020,
|
||||
[ValidateRange(1, 1000)] [int]$FreeGiBFloor = 360
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Get-DFreeBytes { return [int64](Get-PSDrive -Name D).Free }
|
||||
|
||||
if (-not $TokenStdin) { throw "Persistent shadow run requires the token through stdin" }
|
||||
$token = [Console]::In.ReadLine()
|
||||
if (-not $token -or $token.Length -lt 40 -or $token.Length -gt 512) {
|
||||
throw "Persistent shadow token is missing or malformed"
|
||||
}
|
||||
|
||||
$outputRoot = Resolve-DDirectory $PersistentOutputRoot "Persistent output root"
|
||||
$freeBefore = Get-DFreeBytes
|
||||
if ($freeBefore -lt ([int64]$FreeGiBFloor * 1GB + 512MB)) {
|
||||
throw "D: lacks the guarded persistent-run reserve"
|
||||
}
|
||||
$containerRunning = docker inspect --format "{{.State.Running}}" $PersistentContainer
|
||||
Assert-LastExitCode "Persistent worker inspection"
|
||||
if ($containerRunning.Trim().ToLowerInvariant() -ne "true") {
|
||||
throw "Persistent worker is not running"
|
||||
}
|
||||
$outputName = $RequestId
|
||||
$request = @{
|
||||
request_id = $RequestId
|
||||
output_name = $outputName
|
||||
token = $token
|
||||
} | ConvertTo-Json -Compress
|
||||
$token = $null
|
||||
$client = (
|
||||
"import sys,urllib.request,urllib.error;data=sys.stdin.buffer.read();" +
|
||||
"request=urllib.request.Request('http://127.0.0.1:{0}/run',data=data," -f $PersistentPort
|
||||
) + "headers={'Content-Type':'application/json'},method='POST');" +
|
||||
"`ntry:`n response=urllib.request.urlopen(request,timeout=3600); body=response.read(); code=response.status" +
|
||||
"`nexcept urllib.error.HTTPError as exc:`n body=exc.read(); code=exc.code" +
|
||||
"`nsys.stdout.buffer.write(body);raise SystemExit(0 if code==200 else 22)"
|
||||
try {
|
||||
$responseJson = $request | & docker exec -i $PersistentContainer python3 -c $client
|
||||
$request = $null
|
||||
Assert-LastExitCode "Persistent worker request"
|
||||
}
|
||||
finally {
|
||||
$token = $null
|
||||
$request = $null
|
||||
}
|
||||
$response = $responseJson | ConvertFrom-Json
|
||||
if ($response.request_id -ne $RequestId -or $response.models_reused -ne $true) {
|
||||
throw "Persistent worker response contract changed"
|
||||
}
|
||||
$staging = Join-Path $outputRoot $outputName
|
||||
$resultPath = Join-Path $staging "result.json"
|
||||
if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) {
|
||||
throw "Persistent worker result manifest is missing"
|
||||
}
|
||||
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.e15-shadow-inference-result/v1" -or
|
||||
$result.result_id -notmatch "^e15-shadow-inference-[a-f0-9]{64}$" -or
|
||||
$result.publication_scope -ne "live-shadow-diagnostic-only"
|
||||
) { throw "Persistent worker result manifest is incompatible" }
|
||||
$derivedRoot = Resolve-DDirectory (Split-Path $outputRoot -Parent) "Runtime derived root"
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "Immutable persistent-worker result already exists"
|
||||
}
|
||||
Move-Item -LiteralPath $staging -Destination $finalRoot
|
||||
$freeFinal = Get-DFreeBytes
|
||||
if ($freeFinal -lt ([int64]$FreeGiBFloor * 1GB)) {
|
||||
throw "D: crossed the guarded floor after persistent run"
|
||||
}
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
Write-Output ("ACCEPTANCE_STATE={0}" -f $result.acceptance_state)
|
||||
Write-Output ("RUNNER_EXIT_CODE={0}" -f $response.exit_code)
|
||||
Write-Output "MODELS_REUSED=true"
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_FINAL={0}" -f $freeFinal)
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string]$JobRoot,
|
||||
[Parameter(Mandatory = $true)] [string]$RunnerPath,
|
||||
[Parameter(Mandatory = $true)] [string]$LiveProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$E14ProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$DetectorProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$SemanticProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$ValidFovRoot,
|
||||
[Parameter(Mandatory = $true)] [string]$ProjectionPackRoot,
|
||||
[Parameter(Mandatory = $true)] [string]$PackageRoot,
|
||||
[switch]$PreflightOnly,
|
||||
[switch]$PersistentService,
|
||||
[switch]$TokenStdin,
|
||||
[ValidateRange(5, 3600)] [int]$MaximumDurationSeconds = 20,
|
||||
[ValidateRange(1, 1000)] [int]$FreeGiBFloor = 360,
|
||||
[ValidateRange(1024, 65535)] [int]$SourcePort = 18012,
|
||||
[string]$SourceHost = "host.docker.internal",
|
||||
[string]$SourcePath = "/api/v1/device-plugins/nodedc.device.xgrids-lixelkity-k1/live-perception-shadow",
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
[string]$ModelRoot = "D:\NDC_MISSIONCORE\runtime\models\yolox_s",
|
||||
[string]$MediaRuntimeRoot = "D:\NDC_MISSIONCORE\runtime\derived\perception-e15-media-pyav180-lz445-v1",
|
||||
[string]$TritonContainer = "mission-core-triton",
|
||||
[string]$PersistentContainer = "mission-core-perception-worker",
|
||||
[string]$PersistentOutputRoot = "D:\NDC_MISSIONCORE\runtime\derived\.perception-persistent-publish",
|
||||
[ValidateRange(1024, 65535)] [int]$PersistentPort = 18020,
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3@sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Resolve-DFile([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a regular D: file"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
function Get-DFreeBytes { return [int64](Get-PSDrive -Name D).Free }
|
||||
|
||||
function Assert-FreeSpace([string]$Phase, [int64]$RequiredAdditionalBytes = 0) {
|
||||
$free = Get-DFreeBytes
|
||||
$floor = [int64]$FreeGiBFloor * 1GB
|
||||
Write-Host (
|
||||
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3} REQUIRED_ADDITIONAL_GIB={4}" -f
|
||||
$Phase, $free, [math]::Round($free / 1GB, 3), $FreeGiBFloor,
|
||||
[math]::Round($RequiredAdditionalBytes / 1GB, 3)
|
||||
)
|
||||
if ($free -lt ($floor + $RequiredAdditionalBytes)) {
|
||||
throw "D: lacks the guarded LAB E15 reserve during $Phase"
|
||||
}
|
||||
return $free
|
||||
}
|
||||
|
||||
function Test-TritonModelReady {
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 `
|
||||
-Uri "http://127.0.0.1:8000/v2/models/yolox_s/ready"
|
||||
return $response.StatusCode -eq 200
|
||||
}
|
||||
catch { return $false }
|
||||
}
|
||||
|
||||
$jobDirectory = Resolve-DDirectory $JobRoot "Job root"
|
||||
$runner = Resolve-DFile $RunnerPath "LAB E15 runner"
|
||||
$liveProfile = Resolve-DFile $LiveProfilePath "LAB E15 live profile"
|
||||
$e14Profile = Resolve-DFile $E14ProfilePath "Accepted E14 profile"
|
||||
$detectorProfile = Resolve-DFile $DetectorProfilePath "Detector profile"
|
||||
$semanticProfile = Resolve-DFile $SemanticProfilePath "Semantic profile"
|
||||
$validFov = Resolve-DDirectory $ValidFovRoot "Valid-FOV root"
|
||||
$projectionPack = Resolve-DDirectory $ProjectionPackRoot "E15 projection pack"
|
||||
$package = Resolve-DDirectory $PackageRoot "Mission Core package root"
|
||||
$runtime = Resolve-DDirectory $RuntimeRoot "Runtime root"
|
||||
$model = Resolve-DDirectory $ModelRoot "YOLOX model root"
|
||||
$mediaRuntime = Resolve-DDirectory $MediaRuntimeRoot "E15 media runtime"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
|
||||
foreach ($path in @($liveProfile, $e14Profile, $detectorProfile, $semanticProfile)) {
|
||||
if ((Split-Path $path -Parent) -ne $runnerRoot) {
|
||||
throw "Runner and profiles must share one immutable mount"
|
||||
}
|
||||
}
|
||||
foreach ($dependency in @(
|
||||
"e10_fusion_runtime.py",
|
||||
"e15_shadow_runtime.py",
|
||||
"run_e10_integrated_perception.py",
|
||||
"run_e12_shadow_transport_probe.py",
|
||||
"run_e9_multirate_perception.py",
|
||||
"run_e8_realtime_tracking.py",
|
||||
"run_e5_instance_tracking.py",
|
||||
"run_e4_full_session_segmentation.py",
|
||||
"run_recorded_perception_epoch.py",
|
||||
"run_e3_rectified_segmentation.py",
|
||||
"run_evaluation_prelabels.py"
|
||||
)) {
|
||||
$null = Resolve-DFile (Join-Path $runnerRoot $dependency) "LAB E15 dependency"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $package "k1link\compute\live_perception.py") -PathType Leaf)) {
|
||||
throw "Mission Core package mount lacks live perception synchronization"
|
||||
}
|
||||
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$job.schema_version -ne "missioncore.compute-job/v1" -or
|
||||
$job.job_id -ne (Split-Path $jobDirectory -Leaf) -or
|
||||
$job.input.source_id -ne "sensor.camera.right"
|
||||
) { throw "Compute bootstrap job manifest is incompatible" }
|
||||
$live = Get-Content -LiteralPath $liveProfile -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$live.schema_version -ne "missioncore.e15-shadow-inference-profile/v1" -or
|
||||
$live.mode -ne "replay-shadow-gate" -or
|
||||
[bool]$live.authority.commands_enabled -or
|
||||
[bool]$live.authority.navigation_or_safety_accepted -or
|
||||
$live.transport.pyav_version -ne "18.0.0"
|
||||
) { throw "LAB E15 replay-shadow authority contract changed" }
|
||||
$projection = Get-Content -LiteralPath (Join-Path $projectionPack "manifest.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$projection.schema_version -ne "missioncore.e15-live-projection-pack/v1" -or
|
||||
$projection.identity.source_id -ne "sensor.camera.right" -or
|
||||
$projection.identity.calibration_slot -ne "camera_1" -or
|
||||
$projection.identity.calibration_sha256 -ne $live.source.calibration_sha256
|
||||
) { throw "LAB E15 projection pack binding changed" }
|
||||
$mediaManifest = Get-Content -LiteralPath (Join-Path $mediaRuntime "manifest.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$mediaManifest.schema_version -ne "missioncore.e15-media-runtime/v1" -or
|
||||
$mediaManifest.container_image -ne $ContainerImage -or
|
||||
$mediaManifest.packages.av -ne "18.0.0" -or
|
||||
$mediaManifest.packages.lz4 -ne "4.4.5"
|
||||
) { throw "LAB E15 media runtime identity changed" }
|
||||
|
||||
$derivedRoot = Resolve-DDirectory (Join-Path $runtime "derived") "Runtime derived root"
|
||||
$cacheRoot = Resolve-DDirectory (Join-Path $runtime "cache\perception-e3-models-v1") "EoMT cache"
|
||||
$e3Environment = Resolve-DDirectory (Join-Path $derivedRoot "perception-e3-opencv413092-v1") "E3 environment"
|
||||
$torchEnvironment = Resolve-DDirectory (Join-Path $derivedRoot "perception-p0-env-v1") "Torch environment"
|
||||
$transformersEnvironment = Resolve-DDirectory (Join-Path $derivedRoot "perception-p0-transformers4576-v1") "Transformers environment"
|
||||
$freeBytesBefore = Assert-FreeSpace "preflight" 512MB
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Pinned container image inspection"
|
||||
$tritonState = docker inspect --format "{{.State.Running}}" $TritonContainer
|
||||
Assert-LastExitCode "Triton container inspection"
|
||||
if ($tritonState.Trim().ToLowerInvariant() -ne "true") {
|
||||
throw "LAB E15 requires the existing Triton container"
|
||||
}
|
||||
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$liveProfileName = Split-Path $liveProfile -Leaf
|
||||
$e14ProfileName = Split-Path $e14Profile -Leaf
|
||||
$detectorProfileName = Split-Path $detectorProfile -Leaf
|
||||
$semanticProfileName = Split-Path $semanticProfile -Leaf
|
||||
$projectionMount = "/" + (Split-Path $projectionPack -Leaf)
|
||||
$packageMount = "/" + (Split-Path $package -Leaf)
|
||||
$orchestratorSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$commonMounts = @(
|
||||
"-e", ("PYTHONPATH=/runner:{0}:/opt/media:/opt/transformers:/opt/env" -f $packageMount),
|
||||
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $projectionPack) + (":{0}:ro" -f $projectionMount)),
|
||||
"-v", ((Convert-ToDockerPath $model) + ":/model:ro"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:ro"),
|
||||
"-v", ((Convert-ToDockerPath $e3Environment) + ":/environment:ro"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $mediaRuntime) + ":/opt/media:ro"),
|
||||
"-v", ((Convert-ToDockerPath $package) + (":{0}:ro" -f $packageMount)),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro")
|
||||
)
|
||||
$commonRunnerArgs = @(
|
||||
"--job", "/job",
|
||||
"--live-profile", ("/runner/{0}" -f $liveProfileName),
|
||||
"--e14-profile", ("/runner/{0}" -f $e14ProfileName),
|
||||
"--detector-profile", ("/runner/{0}" -f $detectorProfileName),
|
||||
"--semantic-profile", ("/runner/{0}" -f $semanticProfileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--projection-pack", $projectionMount,
|
||||
"--model-root", "/model",
|
||||
"--cache", "/cache",
|
||||
"--environment", "/environment",
|
||||
"--worker-package", $packageMount
|
||||
)
|
||||
|
||||
if ($PersistentService) {
|
||||
if ($PreflightOnly -or $TokenStdin) {
|
||||
throw "Persistent service cannot be combined with one-shot switches"
|
||||
}
|
||||
$persistentParent = Resolve-DDirectory (Split-Path $PersistentOutputRoot -Parent) "Persistent output parent"
|
||||
if (-not (Test-Path -LiteralPath $PersistentOutputRoot)) {
|
||||
$null = New-Item -ItemType Directory -Path $PersistentOutputRoot
|
||||
}
|
||||
$persistentOutput = Resolve-DDirectory $PersistentOutputRoot "Persistent output root"
|
||||
if ((Split-Path $persistentOutput -Parent) -ne $persistentParent) {
|
||||
throw "Persistent output root must be a direct child of its guarded D: parent"
|
||||
}
|
||||
$runnerSha256 = (Get-FileHash -LiteralPath $runner -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$existingContainerId = docker ps -a --filter ("name=^{0}$" -f $PersistentContainer) --format "{{.ID}}"
|
||||
Assert-LastExitCode "Persistent worker container lookup"
|
||||
if ($existingContainerId) {
|
||||
$labels = (docker inspect --format "{{json .Config.Labels}}" $PersistentContainer) | ConvertFrom-Json
|
||||
Assert-LastExitCode "Persistent worker label inspection"
|
||||
if (
|
||||
$labels.'missioncore.role' -ne "perception-persistent-worker" -or
|
||||
$labels.'missioncore.runner.sha256' -ne $runnerSha256
|
||||
) { throw "Existing persistent worker has a different immutable identity" }
|
||||
$running = docker inspect --format "{{.State.Running}}" $PersistentContainer
|
||||
Assert-LastExitCode "Persistent worker state inspection"
|
||||
if ($running.Trim().ToLowerInvariant() -ne "true") {
|
||||
throw "Matching persistent worker exists but is not running"
|
||||
}
|
||||
$healthProbe = (
|
||||
"import json,urllib.request;d=json.load(urllib.request.urlopen(" +
|
||||
"'http://127.0.0.1:{0}/health',timeout=5));" -f $PersistentPort
|
||||
) + "raise SystemExit(0 if d.get('ok') and d.get('models_loaded') else 2)"
|
||||
$previousErrorPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
& docker exec $PersistentContainer python3 -c $healthProbe 2>$null
|
||||
$healthExitCode = $LASTEXITCODE
|
||||
$ErrorActionPreference = $previousErrorPreference
|
||||
if ($healthExitCode -ne 0) { throw "Existing persistent worker health probe failed" }
|
||||
Write-Output "STATE=persistent-worker-reused"
|
||||
Write-Output ("CONTAINER={0}" -f $PersistentContainer)
|
||||
return
|
||||
}
|
||||
|
||||
if (-not (Test-TritonModelReady)) {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/load" `
|
||||
-Method Post -ContentType "application/json" -Body "{}" *> $null
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
while (-not (Test-TritonModelReady)) {
|
||||
if ([DateTime]::UtcNow -ge $deadline) { throw "YOLOX-S did not become ready" }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
|
||||
$serveArgs = @(
|
||||
"run", "--detach", "--name", $PersistentContainer,
|
||||
"--label", "missioncore.role=perception-persistent-worker",
|
||||
"--label", ("missioncore.runner.sha256={0}" -f $runnerSha256),
|
||||
"--gpus", "all", "--network", ("container:{0}" -f $TritonContainer),
|
||||
"--read-only", "--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g"
|
||||
) + $commonMounts + @(
|
||||
"-v", ((Convert-ToDockerPath $persistentOutput) + ":/publish:rw"),
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "serve"
|
||||
) + $commonRunnerArgs + @(
|
||||
"--host", $SourceHost,
|
||||
"--port", [string]$SourcePort,
|
||||
"--path", $SourcePath,
|
||||
"--triton-url", "http://127.0.0.1:8000",
|
||||
"--output-root", "/publish",
|
||||
"--listen-host", "127.0.0.1",
|
||||
"--listen-port", [string]$PersistentPort,
|
||||
"--max-duration-seconds", [string]$MaximumDurationSeconds,
|
||||
"--free-bytes-floor", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--orchestrator-sha256", $orchestratorSha256,
|
||||
"--container-image", $ContainerImage
|
||||
)
|
||||
Write-Output "PHASE=persistent-worker-model-load-start"
|
||||
& docker @serveArgs
|
||||
Assert-LastExitCode "Persistent worker container start"
|
||||
$healthProbe = (
|
||||
"import json,urllib.request;d=json.load(urllib.request.urlopen(" +
|
||||
"'http://127.0.0.1:{0}/health',timeout=5));" -f $PersistentPort
|
||||
) + "print(json.dumps(d,sort_keys=True));raise SystemExit(0 if d.get('ok') and d.get('models_loaded') else 2)"
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(240)
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
$running = docker inspect --format "{{.State.Running}}" $PersistentContainer
|
||||
if ($LASTEXITCODE -ne 0 -or $running.Trim().ToLowerInvariant() -ne "true") {
|
||||
& docker logs --tail 80 $PersistentContainer
|
||||
throw "Persistent worker exited during model load"
|
||||
}
|
||||
$previousErrorPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
$health = & docker exec $PersistentContainer python3 -c $healthProbe 2>$null
|
||||
$healthExitCode = $LASTEXITCODE
|
||||
$ErrorActionPreference = $previousErrorPreference
|
||||
$healthy = $healthExitCode -eq 0
|
||||
} while (-not $healthy -and [DateTime]::UtcNow -lt $deadline)
|
||||
if (-not $healthy) {
|
||||
& docker logs --tail 80 $PersistentContainer
|
||||
throw "Persistent worker model load did not become ready"
|
||||
}
|
||||
Write-Output "STATE=persistent-worker-ready"
|
||||
Write-Output ("CONTAINER={0}" -f $PersistentContainer)
|
||||
Write-Output ("HEALTH={0}" -f $health)
|
||||
Write-Output ("DISK_FREE_BYTES={0}" -f (Get-DFreeBytes))
|
||||
return
|
||||
}
|
||||
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g"
|
||||
) + $commonMounts + @(
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "preflight"
|
||||
) + $commonRunnerArgs
|
||||
Write-Output "PHASE=e15-preflight-start"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "LAB E15 preflight"
|
||||
Write-Output "PHASE=e15-preflight-complete"
|
||||
if ($PreflightOnly) {
|
||||
Write-Output "STATE=preflight-ready"
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_AFTER={0}" -f (Get-DFreeBytes))
|
||||
return
|
||||
}
|
||||
if (-not $TokenStdin) { throw "LAB E15 requires the shadow token through stdin" }
|
||||
$shadowToken = [Console]::In.ReadLine()
|
||||
if (-not $shadowToken -or $shadowToken.Length -lt 40 -or $shadowToken.Length -gt 512) {
|
||||
throw "LAB E15 shadow token is missing or malformed"
|
||||
}
|
||||
|
||||
$modelWasReady = Test-TritonModelReady
|
||||
$loadedByRun = $false
|
||||
if (-not $modelWasReady) {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/load" `
|
||||
-Method Post -ContentType "application/json" -Body "{}" *> $null
|
||||
$loadedByRun = $true
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
while (-not (Test-TritonModelReady)) {
|
||||
if ([DateTime]::UtcNow -ge $deadline) { throw "YOLOX-S did not become ready" }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
Write-Output ("PHASE=e15-model-ready LOADED_BY_RUN={0}" -f $loadedByRun)
|
||||
|
||||
$token = [Guid]::NewGuid().ToString("N")
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-e15-{1}.publish" -f $job.job_id, $token)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
$completed = $false
|
||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
try {
|
||||
$runArgs = @(
|
||||
"run", "--rm", "--interactive", "--gpus", "all",
|
||||
"--network", ("container:{0}" -f $TritonContainer),
|
||||
"--read-only", "--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g"
|
||||
) + $commonMounts + @(
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "run"
|
||||
) + $commonRunnerArgs + @(
|
||||
"--host", $SourceHost,
|
||||
"--port", [string]$SourcePort,
|
||||
"--path", $SourcePath,
|
||||
"--token-stdin",
|
||||
"--triton-url", "http://127.0.0.1:8000",
|
||||
"--output", "/publish/output",
|
||||
"--max-duration-seconds", [string]$MaximumDurationSeconds,
|
||||
"--free-bytes-floor", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--orchestrator-sha256", $orchestratorSha256,
|
||||
"--container-image", $ContainerImage
|
||||
)
|
||||
Write-Output ("PHASE=e15-shadow-inference-start MAX_DURATION_SECONDS={0}" -f $MaximumDurationSeconds)
|
||||
$shadowToken | & docker @runArgs
|
||||
$runnerExitCode = $LASTEXITCODE
|
||||
$shadowToken = $null
|
||||
if ($runnerExitCode -ne 0 -and $runnerExitCode -ne 2) {
|
||||
throw "LAB E15 shadow inference failed with exit code $runnerExitCode"
|
||||
}
|
||||
$null = Assert-FreeSpace "post-shadow-inference"
|
||||
|
||||
$resultPath = Join-Path $stagingRoot "result.json"
|
||||
if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) {
|
||||
throw "LAB E15 result manifest is missing"
|
||||
}
|
||||
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.e15-shadow-inference-result/v1" -or
|
||||
$result.result_id -notmatch "^e15-shadow-inference-[a-f0-9]{64}$" -or
|
||||
$result.publication_scope -ne "live-shadow-diagnostic-only"
|
||||
) { throw "LAB E15 result manifest is incompatible" }
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) { throw "Immutable LAB E15 result already exists" }
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
$completed = $true
|
||||
$totalWatch.Stop()
|
||||
$freeFinal = Assert-FreeSpace "post-publication"
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
Write-Output ("ACCEPTANCE_STATE={0}" -f $result.acceptance_state)
|
||||
Write-Output ("RUNNER_EXIT_CODE={0}" -f $runnerExitCode)
|
||||
Write-Output ("ORCHESTRATOR_WALL_SECONDS={0}" -f $totalWatch.Elapsed.TotalSeconds)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_FINAL={0}" -f $freeFinal)
|
||||
}
|
||||
finally {
|
||||
$shadowToken = $null
|
||||
if (-not $completed -and (Test-Path -LiteralPath $publishRoot)) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
if ($loadedByRun) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/unload" `
|
||||
-Method Post -ContentType "application/json" -Body "{}" *> $null
|
||||
Write-Output "PHASE=e15-model-state-restored"
|
||||
}
|
||||
catch { Write-Warning "LAB E15 could not restore the prior YOLOX-S state" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EvaluationPack,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$E2Prelabels,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ValidFovRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaselineRunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProfilePath,
|
||||
|
||||
[ValidateRange(0, 64)]
|
||||
[int]$MaxFrames = 0,
|
||||
|
||||
[ValidateRange(1, 1000)]
|
||||
[int]$FreeGiBFloor = 360,
|
||||
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegularFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (-not $item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a regular file"
|
||||
}
|
||||
|
||||
function Assert-Directory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a real directory"
|
||||
}
|
||||
|
||||
function Assert-DDrivePath {
|
||||
param([string]$Path, [string]$Label)
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetPathRoot($fullPath).TrimEnd("\") -ine "D:") {
|
||||
throw "$Label must be stored on D:"
|
||||
}
|
||||
return $fullPath
|
||||
}
|
||||
|
||||
function Assert-FreeSpace {
|
||||
param([string]$Phase)
|
||||
$freeBytes = [int64](Get-PSDrive -Name D).Free
|
||||
$floorBytes = [int64]$FreeGiBFloor * 1GB
|
||||
$freeGiB = [math]::Round($freeBytes / 1GB, 3)
|
||||
Write-Output ("DISK_GUARD PHASE={0} DRIVE=D FREE_GIB={1} FLOOR_GIB={2}" -f $Phase, $freeGiB, $FreeGiBFloor)
|
||||
if ($freeBytes -lt $floorBytes) {
|
||||
throw "D: free-space floor was crossed during $Phase"
|
||||
}
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
$packRoot = Assert-DDrivePath (Assert-Directory (Resolve-Path -LiteralPath $EvaluationPack).Path "Evaluation pack") "Evaluation pack"
|
||||
$prelabelsRoot = Assert-DDrivePath (Assert-Directory (Resolve-Path -LiteralPath $E2Prelabels).Path "E2 prelabels") "E2 prelabels"
|
||||
$validFov = Assert-DDrivePath (Assert-Directory (Resolve-Path -LiteralPath $ValidFovRoot).Path "Valid-FOV root") "Valid-FOV root"
|
||||
$runner = Assert-DDrivePath (Assert-RegularFile (Resolve-Path -LiteralPath $RunnerPath).Path "LAB E3 runner") "LAB E3 runner"
|
||||
$baselineRunner = Assert-DDrivePath (Assert-RegularFile (Resolve-Path -LiteralPath $BaselineRunnerPath).Path "Baseline runner") "Baseline runner"
|
||||
$profile = Assert-DDrivePath (Assert-RegularFile (Resolve-Path -LiteralPath $ProfilePath).Path "LAB E3 profile") "LAB E3 profile"
|
||||
$runtime = Assert-DDrivePath (Assert-Directory (Resolve-Path -LiteralPath $RuntimeRoot).Path "Runtime root") "Runtime root"
|
||||
if (
|
||||
(Split-Path $runner -Parent) -ne (Split-Path $baselineRunner -Parent) -or
|
||||
(Split-Path $runner -Parent) -ne (Split-Path $profile -Parent)
|
||||
) {
|
||||
throw "LAB E3 runner, baseline runner, and profile must share one read-only mount"
|
||||
}
|
||||
|
||||
$pack = Get-Content -LiteralPath (Join-Path $packRoot "manifest.json") -Raw | ConvertFrom-Json
|
||||
$prelabels = Get-Content -LiteralPath (Join-Path $prelabelsRoot "result.json") -Raw | ConvertFrom-Json
|
||||
$profileDocument = Get-Content -LiteralPath $profile -Raw | ConvertFrom-Json
|
||||
$packFrameCount = @($pack.identity.frames).Count
|
||||
if (
|
||||
$pack.schema_version -ne "missioncore.perception-evaluation-pack/v1" -or
|
||||
$pack.generation_id -ne (Split-Path $packRoot -Leaf) -or
|
||||
$pack.identity.source_id -ne "sensor.camera.right" -or
|
||||
$pack.identity.calibration_slot -ne "camera_1" -or
|
||||
$packFrameCount -ne 64 -or
|
||||
$prelabels.schema_version -ne "missioncore.perception-evaluation-prelabels/v1" -or
|
||||
$prelabels.identity.evaluation_pack_id -ne $pack.generation_id -or
|
||||
$profileDocument.schema_version -ne "missioncore.k1-e3-rectified-segmentation-profile/v1" -or
|
||||
$profileDocument.source.source_id -ne $pack.identity.source_id -or
|
||||
$profileDocument.source.calibration_slot -ne $pack.identity.calibration_slot -or
|
||||
$profileDocument.source.calibration_sha256 -ne $pack.identity.calibration_sha256
|
||||
) {
|
||||
throw "LAB E3 inputs are incompatible"
|
||||
}
|
||||
$null = Assert-RegularFile (Join-Path $validFov "manifest.json") "Valid-FOV manifest"
|
||||
$null = Assert-RegularFile (Join-Path $validFov "mask.png") "Valid-FOV mask"
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Existing container image inspection"
|
||||
Assert-FreeSpace "preflight"
|
||||
|
||||
$cacheRoot = Join-Path $runtime "cache\perception-e3-models-v1"
|
||||
$derivedRoot = Join-Path $runtime "derived\e3-segmentation"
|
||||
$environmentRoot = Join-Path $runtime "derived\perception-e3-opencv413092-v1"
|
||||
$torchEnvironment = Assert-Directory (Join-Path $runtime "derived\perception-p0-env-v1") "Torch environment"
|
||||
$transformersEnvironment = Assert-Directory (Join-Path $runtime "derived\perception-p0-transformers4576-v1") "Transformers environment"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$profileName = Split-Path $profile -Leaf
|
||||
$null = New-Item -ItemType Directory -Path $cacheRoot -Force
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
|
||||
if (-not (Test-Path -LiteralPath $environmentRoot)) {
|
||||
$environmentToken = [Guid]::NewGuid().ToString("N")
|
||||
$environmentStaging = Join-Path (Split-Path $environmentRoot -Parent) (".e3-environment-{0}.publish" -f $environmentToken)
|
||||
$null = New-Item -ItemType Directory -Path $environmentStaging
|
||||
$null = New-Item -ItemType Directory -Path (Join-Path $environmentStaging "tmp")
|
||||
try {
|
||||
$prepareArgs = @(
|
||||
"run", "--rm", "--network", "bridge", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--tmpfs", "/tmp:rw,noexec,nosuid,size=1g",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "PIP_NO_CACHE_DIR=1",
|
||||
"-e", "HOME=/environment/tmp",
|
||||
"-e", "TMPDIR=/environment/tmp",
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:rw"),
|
||||
"-v", ((Convert-ToDockerPath $environmentStaging) + ":/environment:rw"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "prepare",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--cache", "/cache",
|
||||
"--environment", "/environment"
|
||||
)
|
||||
Write-Output "PHASE=e3-dependency-prepare-start"
|
||||
& docker @prepareArgs
|
||||
Assert-LastExitCode "LAB E3 dependency preparation"
|
||||
$null = Assert-RegularFile (Join-Path $environmentStaging "manifest.json") "LAB E3 dependency manifest"
|
||||
Remove-Item -LiteralPath (Join-Path $environmentStaging "tmp") -Recurse -Force
|
||||
Move-Item -LiteralPath $environmentStaging -Destination $environmentRoot
|
||||
Write-Output "PHASE=e3-dependency-prepare-complete"
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $environmentStaging) {
|
||||
Remove-Item -LiteralPath $environmentStaging -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
$environment = Assert-Directory $environmentRoot "LAB E3 dependency environment"
|
||||
$null = Assert-RegularFile (Join-Path $environment "manifest.json") "LAB E3 dependency manifest"
|
||||
Assert-FreeSpace "post-dependency-prepare"
|
||||
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-{1}.publish" -f $pack.generation_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
|
||||
try {
|
||||
$runArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-e", "HOME=/tmp",
|
||||
"-v", ((Convert-ToDockerPath $packRoot) + ":/evaluation-pack:ro"),
|
||||
"-v", ((Convert-ToDockerPath $prelabelsRoot) + ":/e2-prelabels:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:ro"),
|
||||
"-v", ((Convert-ToDockerPath $environment) + ":/environment:ro"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "run",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--evaluation-pack", "/evaluation-pack",
|
||||
"--e2-prelabels", "/e2-prelabels",
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--cache", "/cache",
|
||||
"--environment", "/environment",
|
||||
"--output", "/publish/output",
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
if ($MaxFrames -gt 0) {
|
||||
$runArgs += @("--max-frames", [string]$MaxFrames)
|
||||
}
|
||||
$expectedFrames = if ($MaxFrames -gt 0) { $MaxFrames } else { $packFrameCount }
|
||||
Write-Output ("PHASE=e3-run-start PACK={0} FRAMES={1}" -f $pack.generation_id, $expectedFrames)
|
||||
& docker @runArgs
|
||||
Assert-LastExitCode "LAB E3 segmentation"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.k1-e3-rectified-segmentation-result/v1" -or
|
||||
$result.result_id -notmatch "^e3-segmentation-[a-f0-9]{64}$" -or
|
||||
$result.identity.evaluation_pack_id -ne $pack.generation_id -or
|
||||
[int]$result.identity.frame_count -ne $expectedFrames -or
|
||||
$result.ground_truth -ne $false
|
||||
) {
|
||||
throw "LAB E3 result manifest is incompatible"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "An immutable LAB E3 result with the same identity already exists: $finalRoot"
|
||||
}
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
Assert-FreeSpace "post-run"
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $publishRoot) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$JobRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProfilePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ValidFovRoot,
|
||||
|
||||
[ValidateRange(0, 256)]
|
||||
[int]$PilotFrames = 0,
|
||||
|
||||
[ValidateRange(1, 1000)]
|
||||
[int]$FreeGiBFloor = 360,
|
||||
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegularFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (-not $item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a regular file"
|
||||
}
|
||||
|
||||
function Assert-Directory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a real directory"
|
||||
}
|
||||
|
||||
function Assert-DDrivePath {
|
||||
param([string]$Path, [string]$Label)
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetPathRoot($fullPath).TrimEnd("\") -ine "D:") {
|
||||
throw "$Label must be stored on D:"
|
||||
}
|
||||
return $fullPath
|
||||
}
|
||||
|
||||
function Get-DFreeBytes {
|
||||
return [int64](Get-PSDrive -Name D).Free
|
||||
}
|
||||
|
||||
function Assert-FreeSpace {
|
||||
param(
|
||||
[string]$Phase,
|
||||
[int64]$RequiredAdditionalBytes = 0
|
||||
)
|
||||
$freeBytes = Get-DFreeBytes
|
||||
$floorBytes = [int64]$FreeGiBFloor * 1GB
|
||||
$requiredBytes = $floorBytes + $RequiredAdditionalBytes
|
||||
Write-Host (
|
||||
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3} REQUIRED_ADDITIONAL_GIB={4}" -f
|
||||
$Phase,
|
||||
$freeBytes,
|
||||
[math]::Round($freeBytes / 1GB, 3),
|
||||
$FreeGiBFloor,
|
||||
[math]::Round($RequiredAdditionalBytes / 1GB, 3)
|
||||
)
|
||||
if ($freeBytes -lt $requiredBytes) {
|
||||
throw "D: does not have the guarded LAB E4 working-set reserve during $Phase"
|
||||
}
|
||||
return $freeBytes
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
$jobDirectory = Assert-DDrivePath (Assert-Directory (Resolve-Path -LiteralPath $JobRoot).Path "Job root") "Job root"
|
||||
$runner = Assert-DDrivePath (Assert-RegularFile (Resolve-Path -LiteralPath $RunnerPath).Path "LAB E4 runner") "LAB E4 runner"
|
||||
$profile = Assert-DDrivePath (Assert-RegularFile (Resolve-Path -LiteralPath $ProfilePath).Path "LAB E4 profile") "LAB E4 profile"
|
||||
$validFov = Assert-DDrivePath (Assert-Directory (Resolve-Path -LiteralPath $ValidFovRoot).Path "Valid-FOV root") "Valid-FOV root"
|
||||
$runtime = Assert-DDrivePath (Assert-Directory (Resolve-Path -LiteralPath $RuntimeRoot).Path "Runtime root") "Runtime root"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
if ((Split-Path $profile -Parent) -ne $runnerRoot) {
|
||||
throw "LAB E4 runner and profile must share one read-only mount"
|
||||
}
|
||||
foreach ($dependency in @(
|
||||
"run_recorded_perception_epoch.py",
|
||||
"run_e3_rectified_segmentation.py",
|
||||
"run_evaluation_prelabels.py"
|
||||
)) {
|
||||
$null = Assert-RegularFile (Join-Path $runnerRoot $dependency) "LAB E4 runner dependency"
|
||||
}
|
||||
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if ($job.schema_version -ne "missioncore.compute-job/v1" -or $job.job_id -ne (Split-Path $jobDirectory -Leaf)) {
|
||||
throw "Compute job manifest is incompatible"
|
||||
}
|
||||
$sourceId = [string]$job.input.source_id
|
||||
$epoch = [int]$job.input.codec_epoch
|
||||
$fullFrameCount = [int]$job.input.segment_count
|
||||
$activeFrameCount = if ($PilotFrames -gt 0) { $PilotFrames } else { $fullFrameCount }
|
||||
$timelineStart = [double]$job.input.timeline.start_seconds
|
||||
$timelineEnd = [double]$job.input.timeline.end_seconds
|
||||
$timelineDuration = $timelineEnd - $timelineStart
|
||||
if (
|
||||
$sourceId -ne "sensor.camera.right" -or
|
||||
$fullFrameCount -lt 1 -or
|
||||
$activeFrameCount -gt $fullFrameCount -or
|
||||
$timelineDuration -le 0
|
||||
) {
|
||||
throw "LAB E4 camera job contract is invalid"
|
||||
}
|
||||
Write-Output ("PHASE=job-manifest-validated JOB={0} FRAMES={1}/{2}" -f $job.job_id, $activeFrameCount, $fullFrameCount)
|
||||
|
||||
$epochRoot = Join-Path $jobDirectory ("input\camera\{0}\epoch-{1}" -f $sourceId, $epoch)
|
||||
$epochRoot = Assert-DDrivePath (Assert-Directory $epochRoot "Camera epoch") "Camera epoch"
|
||||
$initPath = Assert-RegularFile (Join-Path $epochRoot "init.mp4") "Camera init"
|
||||
$segmentsRoot = Assert-Directory (Join-Path $epochRoot "segments") "Camera segments"
|
||||
$derivedRoot = Join-Path $runtime "derived"
|
||||
$tmpRoot = Join-Path $runtime "tmp"
|
||||
$cacheRoot = Assert-DDrivePath (Assert-Directory (Join-Path $runtime "cache\perception-e3-models-v1") "EoMT cache") "EoMT cache"
|
||||
$e3Environment = Assert-DDrivePath (Assert-Directory (Join-Path $derivedRoot "perception-e3-opencv413092-v1") "E3 dependency environment") "E3 dependency environment"
|
||||
$torchEnvironment = Assert-DDrivePath (Assert-Directory (Join-Path $derivedRoot "perception-p0-env-v1") "Torch environment") "Torch environment"
|
||||
$transformersEnvironment = Assert-DDrivePath (Assert-Directory (Join-Path $derivedRoot "perception-p0-transformers4576-v1") "Transformers environment") "Transformers environment"
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
$null = New-Item -ItemType Directory -Path $tmpRoot -Force
|
||||
|
||||
# Conservative upper bound: decoded RGB + RGB overlay + one-byte semantic mask,
|
||||
# plus 20% filesystem/encoding overhead and the reconstructed source stream.
|
||||
$pixelWorkingSet = [int64]$activeFrameCount * 800 * 600 * 7
|
||||
$workingSetReserve = [int64][math]::Ceiling(($pixelWorkingSet * 1.2) + [int64]$job.input.byte_length)
|
||||
$freeBytesBefore = Assert-FreeSpace "preflight" $workingSetReserve
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Existing container image inspection"
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$profileName = Split-Path $profile -Leaf
|
||||
$orchestratorSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:ro"),
|
||||
"-v", ((Convert-ToDockerPath $e3Environment) + ":/environment:ro"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "preflight",
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--cache", "/cache",
|
||||
"--environment", "/environment"
|
||||
)
|
||||
Write-Output "PHASE=e4-preflight-start"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "LAB E4 preflight"
|
||||
Write-Output "PHASE=e4-preflight-complete"
|
||||
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$workRoot = Join-Path $tmpRoot ("{0}-e4-{1}" -f $job.job_id, $runToken)
|
||||
$framesRoot = Join-Path $workRoot "frames"
|
||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||
$ptsPath = Join-Path $workRoot "pts.json"
|
||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-e4-{1}.publish" -f $job.job_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$completed = $false
|
||||
Write-Output "PHASE=e4-private-staging-created"
|
||||
|
||||
try {
|
||||
$stream = [IO.File]::Open($streamPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try {
|
||||
$input = [IO.File]::OpenRead($initPath)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
for ($sequence = 1; $sequence -le $activeFrameCount; $sequence++) {
|
||||
$path = Assert-RegularFile (Join-Path $segmentsRoot ("{0}.m4s" -f $sequence)) "Camera segment"
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
if ($sequence % 500 -eq 0 -or $sequence -eq $activeFrameCount) {
|
||||
Write-Output ("PHASE=e4-stream-reconstruction SEGMENTS={0}/{1}" -f $sequence, $activeFrameCount)
|
||||
}
|
||||
}
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
$null = Assert-FreeSpace "post-stream-reconstruction"
|
||||
|
||||
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
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")
|
||||
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"
|
||||
|
||||
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||
$ptsDocument = Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json
|
||||
$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"
|
||||
}
|
||||
$firstEpochSeconds = [double]::Parse(
|
||||
([string]$pts[0].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
)
|
||||
$previousEpochSeconds = -1.0
|
||||
$timelineWriter = [IO.StreamWriter]::new($timelinePath, $false, [Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
for ($index = 0; $index -lt $activeFrameCount; $index++) {
|
||||
$epochSeconds = [double]::Parse(
|
||||
([string]$pts[$index].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
) - $firstEpochSeconds
|
||||
if ($epochSeconds -le $previousEpochSeconds -or $epochSeconds -gt ($timelineDuration + 0.001)) {
|
||||
throw "Decoded LAB E4 timestamps are not strictly monotonic inside the camera timeline"
|
||||
}
|
||||
$row = [ordered]@{
|
||||
frame_index = $index
|
||||
epoch_seconds = $epochSeconds
|
||||
session_seconds = $timelineStart + $epochSeconds
|
||||
}
|
||||
$timelineWriter.WriteLine(($row | ConvertTo-Json -Compress))
|
||||
$previousEpochSeconds = $epochSeconds
|
||||
}
|
||||
$timelineWriter.Flush()
|
||||
}
|
||||
finally {
|
||||
$timelineWriter.Dispose()
|
||||
}
|
||||
$extractWatch.Stop()
|
||||
$freeBytesPostExtract = Assert-FreeSpace "post-frame-extraction"
|
||||
Write-Output ("PHASE=e4-frame-extraction-complete FRAMES={0}" -f $activeFrameCount)
|
||||
|
||||
$runArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $framesRoot) + ":/frames:ro"),
|
||||
"-v", ((Convert-ToDockerPath $workRoot) + ":/work:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:ro"),
|
||||
"-v", ((Convert-ToDockerPath $e3Environment) + ":/environment:ro"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "run",
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--frames", "/frames",
|
||||
"--timeline", "/work/timeline.jsonl",
|
||||
"--cache", "/cache",
|
||||
"--environment", "/environment",
|
||||
"--output", "/publish/output",
|
||||
"--frame-limit", [string]$activeFrameCount,
|
||||
"--free-bytes-floor", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--orchestrator-sha256", $orchestratorSha256,
|
||||
"--container-image", $ContainerImage,
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
Write-Output ("PHASE=e4-inference-start FRAMES={0}" -f $activeFrameCount)
|
||||
& docker @runArgs
|
||||
Assert-LastExitCode "LAB E4 semantic inference"
|
||||
$freeBytesPostInference = Assert-FreeSpace "post-inference"
|
||||
Write-Output "PHASE=e4-inference-complete"
|
||||
|
||||
if ($PilotFrames -gt 0) {
|
||||
$pilotParent = Join-Path $derivedRoot "e4-pilots"
|
||||
$null = New-Item -ItemType Directory -Path $pilotParent -Force
|
||||
$pilotRoot = Join-Path $pilotParent ("pilot-{0}-{1}" -f $activeFrameCount, $runToken)
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $pilotRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
$completed = $true
|
||||
Write-Output ("PILOT_ROOT={0}" -f $pilotRoot)
|
||||
Write-Output ("PILOT_FRAMES={0}" -f $activeFrameCount)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_EXTRACT={0}" -f $freeBytesPostExtract)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_INFERENCE={0}" -f $freeBytesPostInference)
|
||||
return
|
||||
}
|
||||
|
||||
$videoPath = Join-Path $stagingRoot "perception.mp4"
|
||||
$encodeWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$averageFps = $fullFrameCount / $timelineDuration
|
||||
$fpsText = $averageFps.ToString("0.#########", [Globalization.CultureInfo]::InvariantCulture)
|
||||
$durationText = $timelineDuration.ToString("0.#########", [Globalization.CultureInfo]::InvariantCulture)
|
||||
& ffmpeg -hide_banner -loglevel error -framerate $fpsText -i (Join-Path $stagingRoot "overlay-frames\frame-%06d.png") -t $durationText -c:v h264_nvenc -preset p4 -tune hq -rc vbr -cq 21 -b:v 0 -pix_fmt yuv420p -movflags +faststart $videoPath
|
||||
Assert-LastExitCode "LAB E4 video encoding"
|
||||
$encodeWatch.Stop()
|
||||
$null = Assert-FreeSpace "post-video-encoding"
|
||||
Write-Output "PHASE=e4-video-encoding-complete"
|
||||
|
||||
$masksPath = Join-Path $stagingRoot "masks.tar.gz"
|
||||
$archiveWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
& tar.exe -czf $masksPath -C $stagingRoot semantic-masks
|
||||
Assert-LastExitCode "LAB E4 mask archive publication"
|
||||
$archiveWatch.Stop()
|
||||
$freeBytesPostArtifacts = Assert-FreeSpace "post-mask-archive"
|
||||
Write-Output "PHASE=e4-mask-archive-complete"
|
||||
|
||||
$totalWatch.Stop()
|
||||
$finalizeArgs = @(
|
||||
"run", "--rm", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "128", "--tmpfs", "/tmp:rw,noexec,nosuid,size=256m",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-v", ((Convert-ToDockerPath $stagingRoot) + ":/output:rw"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "finalize",
|
||||
"--output", "/output",
|
||||
"--video", "/output/perception.mp4",
|
||||
"--masks", "/output/masks.tar.gz",
|
||||
"--extract-seconds", $extractWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--encode-seconds", $encodeWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--archive-seconds", $archiveWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--wall-seconds", $totalWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--encoder", "ffmpeg-h264_nvenc-p4-cq21-yuv420p-faststart",
|
||||
"--disk-free-before-bytes", [string]$freeBytesBefore,
|
||||
"--disk-free-post-extract-bytes", [string]$freeBytesPostExtract,
|
||||
"--disk-free-post-inference-bytes", [string]$freeBytesPostInference,
|
||||
"--disk-free-post-artifacts-bytes", [string]$freeBytesPostArtifacts,
|
||||
"--disk-floor-bytes", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--working-set-reserve-bytes", [string]$workingSetReserve
|
||||
)
|
||||
& docker @finalizeArgs
|
||||
Assert-LastExitCode "LAB E4 result finalization"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.recorded-perception-result/v2" -or
|
||||
$result.result_id -notmatch "^result-[a-f0-9]{64}$" -or
|
||||
[int]$result.frames_processed -ne $fullFrameCount -or
|
||||
$result.ground_truth -ne $false
|
||||
) {
|
||||
throw "Final LAB E4 result manifest is incompatible"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "An immutable result with the same LAB E4 identity already exists: $finalRoot"
|
||||
}
|
||||
|
||||
foreach ($temporaryChild in @("overlay-frames", "semantic-masks")) {
|
||||
$temporaryPath = Join-Path $stagingRoot $temporaryChild
|
||||
if (Test-Path -LiteralPath $temporaryPath) {
|
||||
Remove-Item -LiteralPath $temporaryPath -Recurse -Force
|
||||
}
|
||||
}
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
$completed = $true
|
||||
$freeBytesFinal = Assert-FreeSpace "post-publication"
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_EXTRACT={0}" -f $freeBytesPostExtract)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_INFERENCE={0}" -f $freeBytesPostInference)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_ARTIFACTS={0}" -f $freeBytesPostArtifacts)
|
||||
Write-Output ("DISK_FREE_BYTES_FINAL={0}" -f $freeBytesFinal)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workRoot) {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force
|
||||
}
|
||||
if (-not $completed -and (Test-Path -LiteralPath $publishRoot)) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$JobRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProfilePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ValidFovRoot,
|
||||
|
||||
[ValidateRange(0, 1000000)]
|
||||
[int]$StartFrame = 1000,
|
||||
|
||||
[ValidateRange(0, 1000000)]
|
||||
[int]$EndFrame = 1600,
|
||||
|
||||
[ValidateRange(1, 1000)]
|
||||
[int]$FreeGiBFloor = 360,
|
||||
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
|
||||
[string]$ModelRoot = "D:\NDC_MISSIONCORE\runtime\models\yolox_s",
|
||||
|
||||
[string]$TritonContainer = "mission-core-triton",
|
||||
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegularFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (-not $item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a regular file"
|
||||
}
|
||||
|
||||
function Assert-Directory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a real directory"
|
||||
}
|
||||
|
||||
function Assert-DDrivePath {
|
||||
param([string]$Path, [string]$Label)
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetPathRoot($fullPath).TrimEnd("\") -ine "D:") {
|
||||
throw "$Label must be stored on D:"
|
||||
}
|
||||
return $fullPath
|
||||
}
|
||||
|
||||
function Get-DFreeBytes {
|
||||
return [int64](Get-PSDrive -Name D).Free
|
||||
}
|
||||
|
||||
function Assert-FreeSpace {
|
||||
param(
|
||||
[string]$Phase,
|
||||
[int64]$RequiredAdditionalBytes = 0
|
||||
)
|
||||
$freeBytes = Get-DFreeBytes
|
||||
$floorBytes = [int64]$FreeGiBFloor * 1GB
|
||||
Write-Host (
|
||||
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3} REQUIRED_ADDITIONAL_GIB={4}" -f
|
||||
$Phase,
|
||||
$freeBytes,
|
||||
[math]::Round($freeBytes / 1GB, 3),
|
||||
$FreeGiBFloor,
|
||||
[math]::Round($RequiredAdditionalBytes / 1GB, 3)
|
||||
)
|
||||
if ($freeBytes -lt ($floorBytes + $RequiredAdditionalBytes)) {
|
||||
throw "D: does not have the guarded LAB E5 working-set reserve during $Phase"
|
||||
}
|
||||
return $freeBytes
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
function Test-TritonModelReady {
|
||||
param([string]$ModelName)
|
||||
try {
|
||||
$response = Invoke-WebRequest `
|
||||
-Uri ("http://127.0.0.1:8000/v2/models/{0}/ready" -f $ModelName) `
|
||||
-Method Get `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 10
|
||||
return $response.StatusCode -eq 200
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$jobDirectory = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $JobRoot).Path "Job root"
|
||||
) "Job root"
|
||||
$runner = Assert-DDrivePath (
|
||||
Assert-RegularFile (Resolve-Path -LiteralPath $RunnerPath).Path "LAB E5 runner"
|
||||
) "LAB E5 runner"
|
||||
$profile = Assert-DDrivePath (
|
||||
Assert-RegularFile (Resolve-Path -LiteralPath $ProfilePath).Path "LAB E5 profile"
|
||||
) "LAB E5 profile"
|
||||
$validFov = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $ValidFovRoot).Path "Valid-FOV root"
|
||||
) "Valid-FOV root"
|
||||
$runtime = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $RuntimeRoot).Path "Runtime root"
|
||||
) "Runtime root"
|
||||
$model = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $ModelRoot).Path "YOLOX model root"
|
||||
) "YOLOX model root"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
if ((Split-Path $profile -Parent) -ne $runnerRoot) {
|
||||
throw "LAB E5 runner and profile must share one read-only mount"
|
||||
}
|
||||
foreach ($dependency in @(
|
||||
"run_recorded_perception_epoch.py",
|
||||
"run_e3_rectified_segmentation.py",
|
||||
"run_evaluation_prelabels.py"
|
||||
)) {
|
||||
$null = Assert-RegularFile (Join-Path $runnerRoot $dependency) "LAB E5 runner dependency"
|
||||
}
|
||||
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if ($job.schema_version -ne "missioncore.compute-job/v1" -or $job.job_id -ne (Split-Path $jobDirectory -Leaf)) {
|
||||
throw "Compute job manifest is incompatible"
|
||||
}
|
||||
$sourceId = [string]$job.input.source_id
|
||||
$epoch = [int]$job.input.codec_epoch
|
||||
$fullFrameCount = [int]$job.input.segment_count
|
||||
$clipFrameCount = $EndFrame - $StartFrame + 1
|
||||
$timelineStart = [double]$job.input.timeline.start_seconds
|
||||
if (
|
||||
$sourceId -ne "sensor.camera.right" -or
|
||||
$StartFrame -lt 0 -or
|
||||
$EndFrame -lt $StartFrame -or
|
||||
$EndFrame -ge $fullFrameCount -or
|
||||
$clipFrameCount -lt 2
|
||||
) {
|
||||
throw "LAB E5 clip escapes the camera job"
|
||||
}
|
||||
Write-Output (
|
||||
"PHASE=job-manifest-validated JOB={0} CLIP={1}-{2} FRAMES={3}" -f
|
||||
$job.job_id, $StartFrame, $EndFrame, $clipFrameCount
|
||||
)
|
||||
|
||||
$epochRoot = Join-Path $jobDirectory ("input\camera\{0}\epoch-{1}" -f $sourceId, $epoch)
|
||||
$epochRoot = Assert-DDrivePath (Assert-Directory $epochRoot "Camera epoch") "Camera epoch"
|
||||
$initPath = Assert-RegularFile (Join-Path $epochRoot "init.mp4") "Camera init"
|
||||
$segmentsRoot = Assert-Directory (Join-Path $epochRoot "segments") "Camera segments"
|
||||
$derivedRoot = Join-Path $runtime "derived"
|
||||
$tmpRoot = Join-Path $runtime "tmp"
|
||||
$pythonEnvironment = Assert-DDrivePath (
|
||||
Assert-Directory (Join-Path $derivedRoot "perception-p0-env-v1") "Python environment"
|
||||
) "Python environment"
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
$null = New-Item -ItemType Directory -Path $tmpRoot -Force
|
||||
|
||||
# Conservative bound: partial reconstructed stream, decoded RGB PNG payload,
|
||||
# RGB overlays, encoded result and 2 GiB of filesystem/codec overhead.
|
||||
$pixelWorkingSet = [int64]$clipFrameCount * 800 * 600 * 6
|
||||
$partialStreamReserve = [int64][math]::Ceiling(
|
||||
([int64]$job.input.byte_length * ($EndFrame + 1) / $fullFrameCount) * 1.2
|
||||
)
|
||||
$workingSetReserve = [int64][math]::Ceiling($pixelWorkingSet * 1.4) + $partialStreamReserve + 2GB
|
||||
$freeBytesBefore = Assert-FreeSpace "preflight" $workingSetReserve
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Existing container image inspection"
|
||||
$tritonState = docker inspect --format "{{.State.Running}}" $TritonContainer
|
||||
Assert-LastExitCode "Triton container inspection"
|
||||
if ($tritonState.Trim().ToLowerInvariant() -ne "true") {
|
||||
throw "LAB E5 requires the existing Triton container"
|
||||
}
|
||||
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$profileName = Split-Path $profile -Leaf
|
||||
$orchestratorSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "128", "--tmpfs", "/tmp:rw,noexec,nosuid,size=256m",
|
||||
"-e", "PYTHONPATH=/runner:/opt/env",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $model) + ":/model:ro"),
|
||||
"-v", ((Convert-ToDockerPath $pythonEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "preflight",
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--model-root", "/model"
|
||||
)
|
||||
Write-Output "PHASE=e5-preflight-start"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "LAB E5 preflight"
|
||||
Write-Output "PHASE=e5-preflight-complete"
|
||||
|
||||
$modelWasReady = Test-TritonModelReady "yolox_s"
|
||||
$loadedByRun = $false
|
||||
if (-not $modelWasReady) {
|
||||
Invoke-WebRequest `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/load" `
|
||||
-Method Post `
|
||||
-ContentType "application/json" `
|
||||
-Body "{}" `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 60 *> $null
|
||||
$loadedByRun = $true
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
while (-not (Test-TritonModelReady "yolox_s")) {
|
||||
if ([DateTime]::UtcNow -ge $deadline) {
|
||||
throw "YOLOX-S did not become ready in Triton"
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
Write-Output ("PHASE=e5-model-ready LOADED_BY_RUN={0}" -f $loadedByRun)
|
||||
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$workRoot = Join-Path $tmpRoot ("{0}-e5-{1}" -f $job.job_id, $runToken)
|
||||
$framesRoot = Join-Path $workRoot "frames"
|
||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||
$ptsPath = Join-Path $workRoot "pts.json"
|
||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-e5-{1}.publish" -f $job.job_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$completed = $false
|
||||
Write-Output "PHASE=e5-private-staging-created"
|
||||
|
||||
try {
|
||||
$stream = [IO.File]::Open(
|
||||
$streamPath,
|
||||
[IO.FileMode]::CreateNew,
|
||||
[IO.FileAccess]::Write,
|
||||
[IO.FileShare]::None
|
||||
)
|
||||
try {
|
||||
$input = [IO.File]::OpenRead($initPath)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
for ($sequence = 1; $sequence -le ($EndFrame + 1); $sequence++) {
|
||||
$path = Assert-RegularFile (
|
||||
Join-Path $segmentsRoot ("{0}.m4s" -f $sequence)
|
||||
) "Camera segment"
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
if ($sequence % 500 -eq 0 -or $sequence -eq ($EndFrame + 1)) {
|
||||
Write-Output (
|
||||
"PHASE=e5-stream-reconstruction SEGMENTS={0}/{1}" -f
|
||||
$sequence, ($EndFrame + 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
$null = Assert-FreeSpace "post-stream-reconstruction"
|
||||
|
||||
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Output "PHASE=e5-frame-extraction-start"
|
||||
$selectFilter = "select='between(n,{0},{1})'" -f $StartFrame, $EndFrame
|
||||
& ffmpeg `
|
||||
-hide_banner -loglevel fatal `
|
||||
-i $streamPath `
|
||||
-map 0:v:0 `
|
||||
-vf $selectFilter `
|
||||
-fps_mode passthrough `
|
||||
(Join-Path $framesRoot "frame-%06d.png")
|
||||
Assert-LastExitCode "LAB E5 camera clip 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 E5 camera timestamp probe"
|
||||
|
||||
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||
$ptsDocument = Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json
|
||||
$pts = @($ptsDocument.frames)
|
||||
if ($decodedFrames.Count -ne $clipFrameCount -or $pts.Count -ne ($EndFrame + 1)) {
|
||||
throw "Decoded LAB E5 frame/timestamp count differs from the selected clip"
|
||||
}
|
||||
$firstEpochSeconds = [double]::Parse(
|
||||
([string]$pts[0].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
)
|
||||
$previousSessionSeconds = -1.0
|
||||
$timelineWriter = [IO.StreamWriter]::new(
|
||||
$timelinePath,
|
||||
$false,
|
||||
[Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
try {
|
||||
for ($localIndex = 0; $localIndex -lt $clipFrameCount; $localIndex++) {
|
||||
$sourceIndex = $StartFrame + $localIndex
|
||||
$epochSeconds = [double]::Parse(
|
||||
([string]$pts[$sourceIndex].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
) - $firstEpochSeconds
|
||||
$sessionSeconds = $timelineStart + $epochSeconds
|
||||
if ($sessionSeconds -le $previousSessionSeconds) {
|
||||
throw "Decoded LAB E5 clip timestamps are not strictly monotonic"
|
||||
}
|
||||
$row = [ordered]@{
|
||||
frame_index = $localIndex
|
||||
sequence = $localIndex + 1
|
||||
source_frame_index = $sourceIndex
|
||||
source_sequence = $sourceIndex + 1
|
||||
epoch_seconds = $epochSeconds
|
||||
session_seconds = $sessionSeconds
|
||||
}
|
||||
$timelineWriter.WriteLine(($row | ConvertTo-Json -Compress))
|
||||
$previousSessionSeconds = $sessionSeconds
|
||||
}
|
||||
$timelineWriter.Flush()
|
||||
}
|
||||
finally {
|
||||
$timelineWriter.Dispose()
|
||||
}
|
||||
$extractWatch.Stop()
|
||||
$freeBytesPostExtract = Assert-FreeSpace "post-frame-extraction"
|
||||
Write-Output ("PHASE=e5-frame-extraction-complete FRAMES={0}" -f $clipFrameCount)
|
||||
|
||||
$runArgs = @(
|
||||
"run", "--rm", "--gpus", "all",
|
||||
"--network", ("container:{0}" -f $TritonContainer),
|
||||
"--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "256", "--shm-size", "1g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=512m",
|
||||
"-e", "PYTHONPATH=/runner:/opt/env",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $model) + ":/model:ro"),
|
||||
"-v", ((Convert-ToDockerPath $pythonEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $framesRoot) + ":/frames:ro"),
|
||||
"-v", ((Convert-ToDockerPath $workRoot) + ":/work:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "run",
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--model-root", "/model",
|
||||
"--frames", "/frames",
|
||||
"--timeline", "/work/timeline.jsonl",
|
||||
"--output", "/publish/output",
|
||||
"--triton-url", "http://127.0.0.1:8000",
|
||||
"--free-bytes-floor", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--orchestrator-sha256", $orchestratorSha256,
|
||||
"--container-image", $ContainerImage,
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
Write-Output ("PHASE=e5-inference-start FRAMES={0}" -f $clipFrameCount)
|
||||
& docker @runArgs
|
||||
Assert-LastExitCode "LAB E5 detector/tracker inference"
|
||||
$freeBytesPostInference = Assert-FreeSpace "post-inference"
|
||||
Write-Output "PHASE=e5-inference-complete"
|
||||
|
||||
$timelineRows = @(Get-Content -LiteralPath $timelinePath | ForEach-Object {
|
||||
$_ | ConvertFrom-Json
|
||||
})
|
||||
$clipSpanSeconds = [double]$timelineRows[-1].session_seconds - [double]$timelineRows[0].session_seconds
|
||||
if ($clipSpanSeconds -le 0) {
|
||||
throw "LAB E5 clip duration is invalid"
|
||||
}
|
||||
$averageFps = ($clipFrameCount - 1) / $clipSpanSeconds
|
||||
$fpsText = $averageFps.ToString("0.#########", [Globalization.CultureInfo]::InvariantCulture)
|
||||
$videoPath = Join-Path $stagingRoot "tracking.mp4"
|
||||
$encodeWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
& ffmpeg `
|
||||
-hide_banner -loglevel error `
|
||||
-framerate $fpsText `
|
||||
-i (Join-Path $stagingRoot "overlay-frames\frame-%06d.png") `
|
||||
-c:v h264_nvenc `
|
||||
-preset p4 `
|
||||
-tune hq `
|
||||
-rc vbr `
|
||||
-cq 21 `
|
||||
-b:v 0 `
|
||||
-pix_fmt yuv420p `
|
||||
-movflags +faststart `
|
||||
$videoPath
|
||||
Assert-LastExitCode "LAB E5 video encoding"
|
||||
$encodeWatch.Stop()
|
||||
Write-Output "PHASE=e5-video-encoding-complete"
|
||||
|
||||
$contactSheetPath = Join-Path $stagingRoot "contact-sheet.png"
|
||||
$tileColumns = if ($clipFrameCount -le 60) { 4 } else { 3 }
|
||||
$tileRows = 2
|
||||
$sampleInterval = [math]::Max(0.25, $clipSpanSeconds / ($tileColumns * $tileRows))
|
||||
$sampleText = $sampleInterval.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture)
|
||||
$contactFilter = "fps=1/{0},scale=400:-1,tile={1}x{2}:padding=8:margin=8:color=black" -f `
|
||||
$sampleText, $tileColumns, $tileRows
|
||||
& ffmpeg `
|
||||
-hide_banner -loglevel error `
|
||||
-i $videoPath `
|
||||
-vf $contactFilter `
|
||||
-frames:v 1 `
|
||||
$contactSheetPath
|
||||
Assert-LastExitCode "LAB E5 contact-sheet generation"
|
||||
|
||||
$probe = & ffprobe `
|
||||
-v error `
|
||||
-select_streams v:0 `
|
||||
-count_frames `
|
||||
-show_entries stream=codec_name,width,height,nb_read_frames `
|
||||
-of json `
|
||||
$videoPath | ConvertFrom-Json
|
||||
Assert-LastExitCode "LAB E5 result video probe"
|
||||
$videoStream = @($probe.streams)[0]
|
||||
if (
|
||||
$videoStream.codec_name -ne "h264" -or
|
||||
[int]$videoStream.width -ne 800 -or
|
||||
[int]$videoStream.height -ne 600 -or
|
||||
[int]$videoStream.nb_read_frames -ne $clipFrameCount
|
||||
) {
|
||||
throw "LAB E5 result video contract changed"
|
||||
}
|
||||
$freeBytesPostArtifacts = Assert-FreeSpace "post-artifacts"
|
||||
|
||||
$totalWatch.Stop()
|
||||
$finalizeArgs = @(
|
||||
"run", "--rm", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "128", "--tmpfs", "/tmp:rw,noexec,nosuid,size=256m",
|
||||
"-e", "PYTHONPATH=/runner",
|
||||
"-v", ((Convert-ToDockerPath $stagingRoot) + ":/output:rw"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "finalize",
|
||||
"--output", "/output",
|
||||
"--video", "/output/tracking.mp4",
|
||||
"--contact-sheet", "/output/contact-sheet.png",
|
||||
"--extract-seconds", $extractWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--encode-seconds", $encodeWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--wall-seconds", $totalWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--encoder", "ffmpeg-h264_nvenc-p4-cq21-yuv420p-faststart",
|
||||
"--disk-free-before-bytes", [string]$freeBytesBefore,
|
||||
"--disk-free-post-extract-bytes", [string]$freeBytesPostExtract,
|
||||
"--disk-free-post-inference-bytes", [string]$freeBytesPostInference,
|
||||
"--disk-free-post-artifacts-bytes", [string]$freeBytesPostArtifacts,
|
||||
"--disk-floor-bytes", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--working-set-reserve-bytes", [string]$workingSetReserve
|
||||
)
|
||||
& docker @finalizeArgs
|
||||
Assert-LastExitCode "LAB E5 result finalization"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.e5-tracking-result/v1" -or
|
||||
$result.result_id -notmatch "^e5-tracking-[a-f0-9]{64}$" -or
|
||||
[int]$result.frames_processed -ne $clipFrameCount -or
|
||||
$result.ground_truth -ne $false -or
|
||||
$result.publication_scope -ne "qualification-clip-only"
|
||||
) {
|
||||
throw "Final LAB E5 result manifest is incompatible"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "An immutable result with the same LAB E5 identity already exists: $finalRoot"
|
||||
}
|
||||
$overlayFrames = Join-Path $stagingRoot "overlay-frames"
|
||||
if (Test-Path -LiteralPath $overlayFrames) {
|
||||
Remove-Item -LiteralPath $overlayFrames -Recurse -Force
|
||||
}
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
$completed = $true
|
||||
$freeBytesFinal = Assert-FreeSpace "post-publication"
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_EXTRACT={0}" -f $freeBytesPostExtract)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_INFERENCE={0}" -f $freeBytesPostInference)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_ARTIFACTS={0}" -f $freeBytesPostArtifacts)
|
||||
Write-Output ("DISK_FREE_BYTES_FINAL={0}" -f $freeBytesFinal)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workRoot) {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force
|
||||
}
|
||||
if (-not $completed -and (Test-Path -LiteralPath $publishRoot)) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
if ($loadedByRun) {
|
||||
try {
|
||||
Invoke-WebRequest `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/unload" `
|
||||
-Method Post `
|
||||
-ContentType "application/json" `
|
||||
-Body "{}" `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 60 *> $null
|
||||
Write-Output "PHASE=e5-model-state-restored"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "LAB E5 could not restore the prior unloaded YOLOX-S state"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,411 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$JobRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProfilePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ValidFovRoot,
|
||||
|
||||
[ValidateRange(0, 1000000)]
|
||||
[int]$StartFrame = 1000,
|
||||
|
||||
[ValidateRange(0, 1000000)]
|
||||
[int]$EndFrame = 1600,
|
||||
|
||||
[ValidateRange(1, 1000)]
|
||||
[int]$FreeGiBFloor = 360,
|
||||
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
|
||||
[string]$ModelRoot = "D:\NDC_MISSIONCORE\runtime\models\yolox_s",
|
||||
|
||||
[string]$TritonContainer = "mission-core-triton",
|
||||
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegularFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (-not $item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a regular file"
|
||||
}
|
||||
|
||||
function Assert-Directory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a real directory"
|
||||
}
|
||||
|
||||
function Assert-DDrivePath {
|
||||
param([string]$Path, [string]$Label)
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
if ([IO.Path]::GetPathRoot($fullPath).TrimEnd("\") -ine "D:") {
|
||||
throw "$Label must be stored on D:"
|
||||
}
|
||||
return $fullPath
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
function Get-DFreeBytes {
|
||||
return [int64](Get-PSDrive -Name D).Free
|
||||
}
|
||||
|
||||
function Assert-FreeSpace {
|
||||
param([string]$Phase, [int64]$RequiredAdditionalBytes = 0)
|
||||
$freeBytes = Get-DFreeBytes
|
||||
$floorBytes = [int64]$FreeGiBFloor * 1GB
|
||||
Write-Host (
|
||||
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3} REQUIRED_ADDITIONAL_GIB={4}" -f
|
||||
$Phase,
|
||||
$freeBytes,
|
||||
[math]::Round($freeBytes / 1GB, 3),
|
||||
$FreeGiBFloor,
|
||||
[math]::Round($RequiredAdditionalBytes / 1GB, 3)
|
||||
)
|
||||
if ($freeBytes -lt ($floorBytes + $RequiredAdditionalBytes)) {
|
||||
throw "D: does not have the guarded LAB E8 working-set reserve during $Phase"
|
||||
}
|
||||
return $freeBytes
|
||||
}
|
||||
|
||||
function Test-TritonModelReady {
|
||||
try {
|
||||
$response = Invoke-WebRequest `
|
||||
-Uri "http://127.0.0.1:8000/v2/models/yolox_s/ready" `
|
||||
-Method Get -UseBasicParsing -TimeoutSec 10
|
||||
return $response.StatusCode -eq 200
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$jobDirectory = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $JobRoot).Path "Job root"
|
||||
) "Job root"
|
||||
$runner = Assert-DDrivePath (
|
||||
Assert-RegularFile (Resolve-Path -LiteralPath $RunnerPath).Path "LAB E8 runner"
|
||||
) "LAB E8 runner"
|
||||
$profile = Assert-DDrivePath (
|
||||
Assert-RegularFile (Resolve-Path -LiteralPath $ProfilePath).Path "LAB E8 profile"
|
||||
) "LAB E8 profile"
|
||||
$validFov = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $ValidFovRoot).Path "Valid-FOV root"
|
||||
) "Valid-FOV root"
|
||||
$runtime = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $RuntimeRoot).Path "Runtime root"
|
||||
) "Runtime root"
|
||||
$model = Assert-DDrivePath (
|
||||
Assert-Directory (Resolve-Path -LiteralPath $ModelRoot).Path "YOLOX model root"
|
||||
) "YOLOX model root"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
if ((Split-Path $profile -Parent) -ne $runnerRoot) {
|
||||
throw "LAB E8 runner and profile must share one read-only mount"
|
||||
}
|
||||
foreach ($dependency in @(
|
||||
"run_e5_instance_tracking.py",
|
||||
"run_recorded_perception_epoch.py",
|
||||
"run_e3_rectified_segmentation.py",
|
||||
"run_evaluation_prelabels.py"
|
||||
)) {
|
||||
$null = Assert-RegularFile (Join-Path $runnerRoot $dependency) "LAB E8 runner dependency"
|
||||
}
|
||||
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if ($job.schema_version -ne "missioncore.compute-job/v1" -or $job.job_id -ne (Split-Path $jobDirectory -Leaf)) {
|
||||
throw "Compute job manifest is incompatible"
|
||||
}
|
||||
$sourceId = [string]$job.input.source_id
|
||||
$epoch = [int]$job.input.codec_epoch
|
||||
$fullFrameCount = [int]$job.input.segment_count
|
||||
$clipFrameCount = $EndFrame - $StartFrame + 1
|
||||
$timelineStart = [double]$job.input.timeline.start_seconds
|
||||
if (
|
||||
$sourceId -ne "sensor.camera.right" -or
|
||||
$StartFrame -lt 0 -or
|
||||
$EndFrame -lt $StartFrame -or
|
||||
$EndFrame -ge $fullFrameCount -or
|
||||
$clipFrameCount -lt 2
|
||||
) {
|
||||
throw "LAB E8 clip escapes the camera job"
|
||||
}
|
||||
Write-Output (
|
||||
"PHASE=job-manifest-validated JOB={0} CLIP={1}-{2} FRAMES={3}" -f
|
||||
$job.job_id, $StartFrame, $EndFrame, $clipFrameCount
|
||||
)
|
||||
|
||||
$epochRoot = Assert-DDrivePath (
|
||||
Assert-Directory (
|
||||
Join-Path $jobDirectory ("input\camera\{0}\epoch-{1}" -f $sourceId, $epoch)
|
||||
) "Camera epoch"
|
||||
) "Camera epoch"
|
||||
$initPath = Assert-RegularFile (Join-Path $epochRoot "init.mp4") "Camera init"
|
||||
$segmentsRoot = Assert-Directory (Join-Path $epochRoot "segments") "Camera segments"
|
||||
$derivedRoot = Join-Path $runtime "derived"
|
||||
$tmpRoot = Join-Path $runtime "tmp"
|
||||
$pythonEnvironment = Assert-DDrivePath (
|
||||
Assert-Directory (Join-Path $derivedRoot "perception-p0-env-v1") "Python environment"
|
||||
) "Python environment"
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
$null = New-Item -ItemType Directory -Path $tmpRoot -Force
|
||||
|
||||
# LAB E8 has temporary decoded input but no per-frame output images. Reserve
|
||||
# decoded RGB payload, partial stream and two GiB for filesystem/codec overhead.
|
||||
$pixelWorkingSet = [int64]$clipFrameCount * 800 * 600 * 3
|
||||
$partialStreamReserve = [int64][math]::Ceiling(
|
||||
([int64]$job.input.byte_length * ($EndFrame + 1) / $fullFrameCount) * 1.2
|
||||
)
|
||||
$workingSetReserve = [int64][math]::Ceiling($pixelWorkingSet * 1.4) + $partialStreamReserve + 2GB
|
||||
$freeBytesBefore = Assert-FreeSpace "preflight" $workingSetReserve
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Existing container image inspection"
|
||||
$tritonState = docker inspect --format "{{.State.Running}}" $TritonContainer
|
||||
Assert-LastExitCode "Triton container inspection"
|
||||
if ($tritonState.Trim().ToLowerInvariant() -ne "true") {
|
||||
throw "LAB E8 requires the existing Triton container"
|
||||
}
|
||||
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$profileName = Split-Path $profile -Leaf
|
||||
$orchestratorSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "128", "--tmpfs", "/tmp:rw,noexec,nosuid,size=256m",
|
||||
"-e", "PYTHONPATH=/runner:/opt/env",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $model) + ":/model:ro"),
|
||||
"-v", ((Convert-ToDockerPath $pythonEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "preflight",
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--model-root", "/model"
|
||||
)
|
||||
Write-Output "PHASE=e8-preflight-start"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "LAB E8 preflight"
|
||||
Write-Output "PHASE=e8-preflight-complete"
|
||||
|
||||
$modelWasReady = Test-TritonModelReady
|
||||
$loadedByRun = $false
|
||||
if (-not $modelWasReady) {
|
||||
Invoke-WebRequest `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/load" `
|
||||
-Method Post -ContentType "application/json" -Body "{}" `
|
||||
-UseBasicParsing -TimeoutSec 60 *> $null
|
||||
$loadedByRun = $true
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
while (-not (Test-TritonModelReady)) {
|
||||
if ([DateTime]::UtcNow -ge $deadline) {
|
||||
throw "YOLOX-S did not become ready in Triton"
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
Write-Output ("PHASE=e8-model-ready LOADED_BY_RUN={0}" -f $loadedByRun)
|
||||
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$workRoot = Join-Path $tmpRoot ("{0}-e8-{1}" -f $job.job_id, $runToken)
|
||||
$framesRoot = Join-Path $workRoot "frames"
|
||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||
$ptsPath = Join-Path $workRoot "pts.json"
|
||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-e8-{1}.publish" -f $job.job_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$completed = $false
|
||||
Write-Output "PHASE=e8-private-staging-created"
|
||||
|
||||
try {
|
||||
$stream = [IO.File]::Open(
|
||||
$streamPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None
|
||||
)
|
||||
try {
|
||||
$input = [IO.File]::OpenRead($initPath)
|
||||
try { $input.CopyTo($stream) } finally { $input.Dispose() }
|
||||
for ($sequence = 1; $sequence -le ($EndFrame + 1); $sequence++) {
|
||||
$path = Assert-RegularFile (
|
||||
Join-Path $segmentsRoot ("{0}.m4s" -f $sequence)
|
||||
) "Camera segment"
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try { $input.CopyTo($stream) } finally { $input.Dispose() }
|
||||
}
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
$null = Assert-FreeSpace "post-stream-reconstruction"
|
||||
|
||||
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Output "PHASE=e8-frame-extraction-start"
|
||||
$selectFilter = "select='between(n,{0},{1})'" -f $StartFrame, $EndFrame
|
||||
& ffmpeg -hide_banner -loglevel fatal -i $streamPath -map 0:v:0 `
|
||||
-vf $selectFilter -fps_mode passthrough (Join-Path $framesRoot "frame-%06d.png")
|
||||
Assert-LastExitCode "LAB E8 camera clip 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 E8 camera timestamp probe"
|
||||
|
||||
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||
$ptsDocument = Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json
|
||||
$pts = @($ptsDocument.frames)
|
||||
if ($decodedFrames.Count -ne $clipFrameCount -or $pts.Count -ne ($EndFrame + 1)) {
|
||||
throw "Decoded LAB E8 frame/timestamp count differs from the selected clip"
|
||||
}
|
||||
$firstEpochSeconds = [double]::Parse(
|
||||
([string]$pts[0].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
)
|
||||
$previousSessionSeconds = -1.0
|
||||
$timelineWriter = [IO.StreamWriter]::new(
|
||||
$timelinePath, $false, [Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
try {
|
||||
for ($localIndex = 0; $localIndex -lt $clipFrameCount; $localIndex++) {
|
||||
$sourceIndex = $StartFrame + $localIndex
|
||||
$epochSeconds = [double]::Parse(
|
||||
([string]$pts[$sourceIndex].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
) - $firstEpochSeconds
|
||||
$sessionSeconds = $timelineStart + $epochSeconds
|
||||
if ($sessionSeconds -le $previousSessionSeconds) {
|
||||
throw "Decoded LAB E8 clip timestamps are not strictly monotonic"
|
||||
}
|
||||
$row = [ordered]@{
|
||||
frame_index = $localIndex
|
||||
sequence = $localIndex + 1
|
||||
source_frame_index = $sourceIndex
|
||||
source_sequence = $sourceIndex + 1
|
||||
epoch_seconds = $epochSeconds
|
||||
session_seconds = $sessionSeconds
|
||||
}
|
||||
$timelineWriter.WriteLine(($row | ConvertTo-Json -Compress))
|
||||
$previousSessionSeconds = $sessionSeconds
|
||||
}
|
||||
$timelineWriter.Flush()
|
||||
}
|
||||
finally {
|
||||
$timelineWriter.Dispose()
|
||||
}
|
||||
$extractWatch.Stop()
|
||||
$freeBytesPostExtract = Assert-FreeSpace "post-frame-extraction"
|
||||
Write-Output ("PHASE=e8-frame-extraction-complete FRAMES={0}" -f $clipFrameCount)
|
||||
|
||||
$runArgs = @(
|
||||
"run", "--rm", "--gpus", "all",
|
||||
"--network", ("container:{0}" -f $TritonContainer),
|
||||
"--read-only", "--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "256", "--shm-size", "1g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=512m",
|
||||
"-e", "PYTHONPATH=/runner:/opt/env",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $model) + ":/model:ro"),
|
||||
"-v", ((Convert-ToDockerPath $pythonEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $framesRoot) + ":/frames:ro"),
|
||||
"-v", ((Convert-ToDockerPath $workRoot) + ":/work:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "run",
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--model-root", "/model",
|
||||
"--frames", "/frames",
|
||||
"--timeline", "/work/timeline.jsonl",
|
||||
"--output", "/publish/output",
|
||||
"--triton-url", "http://127.0.0.1:8000",
|
||||
"--free-bytes-floor", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--orchestrator-sha256", $orchestratorSha256,
|
||||
"--container-image", $ContainerImage,
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
Write-Output ("PHASE=e8-source-paced-replay-start FRAMES={0}" -f $clipFrameCount)
|
||||
& docker @runArgs
|
||||
Assert-LastExitCode "LAB E8 source-paced detector/tracker"
|
||||
$freeBytesPostReplay = Assert-FreeSpace "post-replay"
|
||||
Write-Output "PHASE=e8-source-paced-replay-complete"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.e8-realtime-tracking-result/v1" -or
|
||||
$result.result_id -notmatch "^e8-realtime-tracking-[a-f0-9]{64}$" -or
|
||||
$result.acceptance_state -ne "accepted" -or
|
||||
$result.ground_truth -ne $false
|
||||
) {
|
||||
throw "Final LAB E8 result manifest is incompatible or rejected"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "An immutable result with the same LAB E8 identity already exists: $finalRoot"
|
||||
}
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
$totalWatch.Stop()
|
||||
$completed = $true
|
||||
$freeBytesFinal = Assert-FreeSpace "post-publication"
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
Write-Output ("PREPARATION_SECONDS={0}" -f $extractWatch.Elapsed.TotalSeconds)
|
||||
Write-Output ("ORCHESTRATOR_WALL_SECONDS={0}" -f $totalWatch.Elapsed.TotalSeconds)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_EXTRACT={0}" -f $freeBytesPostExtract)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_REPLAY={0}" -f $freeBytesPostReplay)
|
||||
Write-Output ("DISK_FREE_BYTES_FINAL={0}" -f $freeBytesFinal)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workRoot) {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force
|
||||
}
|
||||
if (-not $completed -and (Test-Path -LiteralPath $publishRoot)) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
if ($loadedByRun) {
|
||||
try {
|
||||
Invoke-WebRequest `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/unload" `
|
||||
-Method Post -ContentType "application/json" -Body "{}" `
|
||||
-UseBasicParsing -TimeoutSec 60 *> $null
|
||||
Write-Output "PHASE=e8-model-state-restored"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "LAB E8 could not restore the prior unloaded YOLOX-S state"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)] [string]$JobRoot,
|
||||
[Parameter(Mandatory = $true)] [string]$RunnerPath,
|
||||
[Parameter(Mandatory = $true)] [string]$ProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$DetectorProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$SemanticProfilePath,
|
||||
[Parameter(Mandatory = $true)] [string]$ValidFovRoot,
|
||||
[ValidateRange(0, 1000000)] [int]$StartFrame = 1000,
|
||||
[ValidateRange(0, 1000000)] [int]$EndFrame = 1600,
|
||||
[ValidateRange(1, 1000)] [int]$FreeGiBFloor = 360,
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
[string]$ModelRoot = "D:\NDC_MISSIONCORE\runtime\models\yolox_s",
|
||||
[string]$TritonContainer = "mission-core-triton",
|
||||
[string]$ContainerImage = "nvcr.io/nvidia/tritonserver:26.06-py3"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Resolve-DDirectory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Resolve-DFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
$root = [IO.Path]::GetPathRoot($item.FullName).TrimEnd("\")
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $root -ine "D:") {
|
||||
throw "$Label must be a regular D: file"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
function Get-DFreeBytes { return [int64](Get-PSDrive -Name D).Free }
|
||||
|
||||
function Assert-FreeSpace {
|
||||
param([string]$Phase, [int64]$RequiredAdditionalBytes = 0)
|
||||
$freeBytes = Get-DFreeBytes
|
||||
$floorBytes = [int64]$FreeGiBFloor * 1GB
|
||||
Write-Host (
|
||||
"DISK_GUARD PHASE={0} DRIVE=D FREE_BYTES={1} FREE_GIB={2} FLOOR_GIB={3} REQUIRED_ADDITIONAL_GIB={4}" -f
|
||||
$Phase, $freeBytes, [math]::Round($freeBytes / 1GB, 3), $FreeGiBFloor,
|
||||
[math]::Round($RequiredAdditionalBytes / 1GB, 3)
|
||||
)
|
||||
if ($freeBytes -lt ($floorBytes + $RequiredAdditionalBytes)) {
|
||||
throw "D: does not have the guarded LAB E9 working-set reserve during $Phase"
|
||||
}
|
||||
return $freeBytes
|
||||
}
|
||||
|
||||
function Test-TritonModelReady {
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -TimeoutSec 10 `
|
||||
-Uri "http://127.0.0.1:8000/v2/models/yolox_s/ready"
|
||||
return $response.StatusCode -eq 200
|
||||
}
|
||||
catch { return $false }
|
||||
}
|
||||
|
||||
$jobDirectory = Resolve-DDirectory $JobRoot "Job root"
|
||||
$runner = Resolve-DFile $RunnerPath "LAB E9 runner"
|
||||
$profile = Resolve-DFile $ProfilePath "LAB E9 profile"
|
||||
$detectorProfile = Resolve-DFile $DetectorProfilePath "LAB E9 detector profile"
|
||||
$semanticProfile = Resolve-DFile $SemanticProfilePath "LAB E9 semantic profile"
|
||||
$validFov = Resolve-DDirectory $ValidFovRoot "Valid-FOV root"
|
||||
$runtime = Resolve-DDirectory $RuntimeRoot "Runtime root"
|
||||
$model = Resolve-DDirectory $ModelRoot "YOLOX model root"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
foreach ($path in @($profile, $detectorProfile, $semanticProfile)) {
|
||||
if ((Split-Path $path -Parent) -ne $runnerRoot) {
|
||||
throw "LAB E9 runner and profiles must share one read-only mount"
|
||||
}
|
||||
}
|
||||
foreach ($dependency in @(
|
||||
"run_e8_realtime_tracking.py",
|
||||
"run_e5_instance_tracking.py",
|
||||
"run_e4_full_session_segmentation.py",
|
||||
"run_recorded_perception_epoch.py",
|
||||
"run_e3_rectified_segmentation.py",
|
||||
"run_evaluation_prelabels.py"
|
||||
)) {
|
||||
$null = Resolve-DFile (Join-Path $runnerRoot $dependency) "LAB E9 runner dependency"
|
||||
}
|
||||
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if ($job.schema_version -ne "missioncore.compute-job/v1" -or $job.job_id -ne (Split-Path $jobDirectory -Leaf)) {
|
||||
throw "Compute job manifest is incompatible"
|
||||
}
|
||||
$sourceId = [string]$job.input.source_id
|
||||
$epoch = [int]$job.input.codec_epoch
|
||||
$fullFrameCount = [int]$job.input.segment_count
|
||||
$clipFrameCount = $EndFrame - $StartFrame + 1
|
||||
$timelineStart = [double]$job.input.timeline.start_seconds
|
||||
if (
|
||||
$sourceId -ne "sensor.camera.right" -or $StartFrame -lt 0 -or
|
||||
$EndFrame -lt $StartFrame -or $EndFrame -ge $fullFrameCount -or $clipFrameCount -lt 2
|
||||
) { throw "LAB E9 clip escapes the camera job" }
|
||||
Write-Output (
|
||||
"PHASE=job-manifest-validated JOB={0} CLIP={1}-{2} FRAMES={3}" -f
|
||||
$job.job_id, $StartFrame, $EndFrame, $clipFrameCount
|
||||
)
|
||||
|
||||
$epochRoot = Resolve-DDirectory (
|
||||
Join-Path $jobDirectory ("input\camera\{0}\epoch-{1}" -f $sourceId, $epoch)
|
||||
) "Camera epoch"
|
||||
$initPath = Resolve-DFile (Join-Path $epochRoot "init.mp4") "Camera init"
|
||||
$segmentsRoot = Resolve-DDirectory (Join-Path $epochRoot "segments") "Camera segments"
|
||||
$derivedRoot = Join-Path $runtime "derived"
|
||||
$tmpRoot = Join-Path $runtime "tmp"
|
||||
$cacheRoot = Resolve-DDirectory (Join-Path $runtime "cache\perception-e3-models-v1") "EoMT cache"
|
||||
$e3Environment = Resolve-DDirectory (Join-Path $derivedRoot "perception-e3-opencv413092-v1") "E3 environment"
|
||||
$torchEnvironment = Resolve-DDirectory (Join-Path $derivedRoot "perception-p0-env-v1") "Torch environment"
|
||||
$transformersEnvironment = Resolve-DDirectory (Join-Path $derivedRoot "perception-p0-transformers4576-v1") "Transformers environment"
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
$null = New-Item -ItemType Directory -Path $tmpRoot -Force
|
||||
|
||||
$pixelWorkingSet = [int64]$clipFrameCount * 800 * 600 * 3
|
||||
$partialStreamReserve = [int64][math]::Ceiling(
|
||||
([int64]$job.input.byte_length * ($EndFrame + 1) / $fullFrameCount) * 1.2
|
||||
)
|
||||
$workingSetReserve = [int64][math]::Ceiling($pixelWorkingSet * 1.4) + $partialStreamReserve + 3GB
|
||||
$freeBytesBefore = Assert-FreeSpace "preflight" $workingSetReserve
|
||||
|
||||
& docker image inspect $ContainerImage *> $null
|
||||
Assert-LastExitCode "Existing container image inspection"
|
||||
$tritonState = docker inspect --format "{{.State.Running}}" $TritonContainer
|
||||
Assert-LastExitCode "Triton container inspection"
|
||||
if ($tritonState.Trim().ToLowerInvariant() -ne "true") {
|
||||
throw "LAB E9 requires the existing Triton container"
|
||||
}
|
||||
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$profileName = Split-Path $profile -Leaf
|
||||
$detectorProfileName = Split-Path $detectorProfile -Leaf
|
||||
$semanticProfileName = Split-Path $semanticProfile -Leaf
|
||||
$orchestratorSha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$commonMounts = @(
|
||||
"-e", "PYTHONPATH=/runner:/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $model) + ":/model:ro"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:ro"),
|
||||
"-v", ((Convert-ToDockerPath $e3Environment) + ":/environment:ro"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro")
|
||||
)
|
||||
$commonRunnerArgs = @(
|
||||
"--job", "/job",
|
||||
"--profile", ("/runner/{0}" -f $profileName),
|
||||
"--detector-profile", ("/runner/{0}" -f $detectorProfileName),
|
||||
"--semantic-profile", ("/runner/{0}" -f $semanticProfileName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--model-root", "/model",
|
||||
"--cache", "/cache",
|
||||
"--environment", "/environment"
|
||||
)
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none", "--read-only",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g"
|
||||
) + $commonMounts + @(
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "preflight"
|
||||
) + $commonRunnerArgs
|
||||
Write-Output "PHASE=e9-preflight-start"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "LAB E9 preflight"
|
||||
Write-Output "PHASE=e9-preflight-complete"
|
||||
|
||||
$modelWasReady = Test-TritonModelReady
|
||||
$loadedByRun = $false
|
||||
if (-not $modelWasReady) {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/load" `
|
||||
-Method Post -ContentType "application/json" -Body "{}" *> $null
|
||||
$loadedByRun = $true
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(60)
|
||||
while (-not (Test-TritonModelReady)) {
|
||||
if ([DateTime]::UtcNow -ge $deadline) { throw "YOLOX-S did not become ready" }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
Write-Output ("PHASE=e9-model-ready LOADED_BY_RUN={0}" -f $loadedByRun)
|
||||
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$workRoot = Join-Path $tmpRoot ("{0}-e9-{1}" -f $job.job_id, $runToken)
|
||||
$framesRoot = Join-Path $workRoot "frames"
|
||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||
$ptsPath = Join-Path $workRoot "pts.json"
|
||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-e9-{1}.publish" -f $job.job_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
$completed = $false
|
||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
try {
|
||||
$stream = [IO.File]::Open($streamPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try {
|
||||
$input = [IO.File]::OpenRead($initPath)
|
||||
try { $input.CopyTo($stream) } finally { $input.Dispose() }
|
||||
for ($sequence = 1; $sequence -le ($EndFrame + 1); $sequence++) {
|
||||
$path = Resolve-DFile (Join-Path $segmentsRoot ("{0}.m4s" -f $sequence)) "Camera segment"
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try { $input.CopyTo($stream) } finally { $input.Dispose() }
|
||||
}
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally { $stream.Dispose() }
|
||||
$null = Assert-FreeSpace "post-stream-reconstruction"
|
||||
|
||||
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$selectFilter = "select='between(n,{0},{1})'" -f $StartFrame, $EndFrame
|
||||
& ffmpeg -hide_banner -loglevel fatal -i $streamPath -map 0:v:0 `
|
||||
-vf $selectFilter -fps_mode passthrough (Join-Path $framesRoot "frame-%06d.png")
|
||||
Assert-LastExitCode "LAB E9 camera clip 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 E9 camera timestamp probe"
|
||||
|
||||
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||
$pts = @((Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json).frames)
|
||||
if ($decodedFrames.Count -ne $clipFrameCount -or $pts.Count -ne ($EndFrame + 1)) {
|
||||
throw "Decoded LAB E9 frame/timestamp count changed"
|
||||
}
|
||||
$firstEpochSeconds = [double]::Parse(
|
||||
([string]$pts[0].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
)
|
||||
$previousSessionSeconds = -1.0
|
||||
$timelineWriter = [IO.StreamWriter]::new(
|
||||
$timelinePath, $false, [Text.UTF8Encoding]::new($false)
|
||||
)
|
||||
try {
|
||||
for ($localIndex = 0; $localIndex -lt $clipFrameCount; $localIndex++) {
|
||||
$sourceIndex = $StartFrame + $localIndex
|
||||
$epochSeconds = [double]::Parse(
|
||||
([string]$pts[$sourceIndex].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
) - $firstEpochSeconds
|
||||
$sessionSeconds = $timelineStart + $epochSeconds
|
||||
if ($sessionSeconds -le $previousSessionSeconds) { throw "LAB E9 timeline is not monotonic" }
|
||||
$row = [ordered]@{
|
||||
frame_index = $localIndex
|
||||
sequence = $localIndex + 1
|
||||
source_frame_index = $sourceIndex
|
||||
source_sequence = $sourceIndex + 1
|
||||
epoch_seconds = $epochSeconds
|
||||
session_seconds = $sessionSeconds
|
||||
}
|
||||
$timelineWriter.WriteLine(($row | ConvertTo-Json -Compress))
|
||||
$previousSessionSeconds = $sessionSeconds
|
||||
}
|
||||
$timelineWriter.Flush()
|
||||
}
|
||||
finally { $timelineWriter.Dispose() }
|
||||
$extractWatch.Stop()
|
||||
$freeBytesPostExtract = Assert-FreeSpace "post-frame-extraction"
|
||||
Write-Output ("PHASE=e9-frame-extraction-complete FRAMES={0}" -f $clipFrameCount)
|
||||
|
||||
$runArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", ("container:{0}" -f $TritonContainer),
|
||||
"--read-only", "--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--pids-limit", "512", "--shm-size", "4g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g"
|
||||
) + $commonMounts + @(
|
||||
"-v", ((Convert-ToDockerPath $framesRoot) + ":/frames:ro"),
|
||||
"-v", ((Convert-ToDockerPath $workRoot) + ":/work:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"--entrypoint", "python3", $ContainerImage,
|
||||
("/runner/{0}" -f $runnerName), "run"
|
||||
) + $commonRunnerArgs + @(
|
||||
"--frames", "/frames",
|
||||
"--timeline", "/work/timeline.jsonl",
|
||||
"--output", "/publish/output",
|
||||
"--triton-url", "http://127.0.0.1:8000",
|
||||
"--free-bytes-floor", [string]([int64]$FreeGiBFloor * 1GB),
|
||||
"--orchestrator-sha256", $orchestratorSha256,
|
||||
"--container-image", $ContainerImage,
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
Write-Output ("PHASE=e9-multirate-replay-start FRAMES={0}" -f $clipFrameCount)
|
||||
& docker @runArgs
|
||||
Assert-LastExitCode "LAB E9 multirate replay"
|
||||
$freeBytesPostReplay = Assert-FreeSpace "post-replay"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.e9-multirate-perception-result/v1" -or
|
||||
$result.result_id -notmatch "^e9-multirate-perception-[a-f0-9]{64}$" -or
|
||||
$result.acceptance_state -notin @("accepted", "rejected") -or
|
||||
$result.ground_truth -ne $false
|
||||
) { throw "LAB E9 result manifest is incompatible" }
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) { throw "Immutable LAB E9 result already exists" }
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
$completed = $true
|
||||
$totalWatch.Stop()
|
||||
$freeBytesFinal = Assert-FreeSpace "post-publication"
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
Write-Output ("ACCEPTANCE_STATE={0}" -f $result.acceptance_state)
|
||||
Write-Output ("PREPARATION_SECONDS={0}" -f $extractWatch.Elapsed.TotalSeconds)
|
||||
Write-Output ("ORCHESTRATOR_WALL_SECONDS={0}" -f $totalWatch.Elapsed.TotalSeconds)
|
||||
Write-Output ("DISK_FREE_BYTES_BEFORE={0}" -f $freeBytesBefore)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_EXTRACT={0}" -f $freeBytesPostExtract)
|
||||
Write-Output ("DISK_FREE_BYTES_POST_REPLAY={0}" -f $freeBytesPostReplay)
|
||||
Write-Output ("DISK_FREE_BYTES_FINAL={0}" -f $freeBytesFinal)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workRoot) { Remove-Item -LiteralPath $workRoot -Recurse -Force }
|
||||
if (-not $completed -and (Test-Path -LiteralPath $publishRoot)) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
if ($loadedByRun) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -TimeoutSec 60 `
|
||||
-Uri "http://127.0.0.1:8000/v2/repository/models/yolox_s/unload" `
|
||||
-Method Post -ContentType "application/json" -Body "{}" *> $null
|
||||
Write-Output "PHASE=e9-model-state-restored"
|
||||
}
|
||||
catch { Write-Warning "LAB E9 could not restore the prior unloaded YOLOX-S state" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EvaluationPack,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ValidFovRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaselineRunnerPath,
|
||||
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegularFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (-not $item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a regular file"
|
||||
}
|
||||
|
||||
function Assert-Directory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a real directory"
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
$packRoot = Assert-Directory (Resolve-Path -LiteralPath $EvaluationPack).Path "Evaluation pack"
|
||||
$validFov = Assert-Directory (Resolve-Path -LiteralPath $ValidFovRoot).Path "Valid-FOV root"
|
||||
$runner = Assert-RegularFile (Resolve-Path -LiteralPath $RunnerPath).Path "Prelabel runner"
|
||||
$baselineRunner = Assert-RegularFile (Resolve-Path -LiteralPath $BaselineRunnerPath).Path "Baseline runner"
|
||||
if ((Split-Path $runner -Parent) -ne (Split-Path $baselineRunner -Parent)) {
|
||||
throw "Prelabel and baseline runners must share one read-only mount"
|
||||
}
|
||||
$runtime = Assert-Directory (Resolve-Path -LiteralPath $RuntimeRoot).Path "Runtime root"
|
||||
$pack = Get-Content -LiteralPath (Join-Path $packRoot "manifest.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$pack.schema_version -ne "missioncore.perception-evaluation-pack/v1" -or
|
||||
$pack.generation_id -ne (Split-Path $packRoot -Leaf) -or
|
||||
$pack.identity.preprocessing_profile -ne "fixed-valid-fov-fill/v1"
|
||||
) {
|
||||
throw "Evaluation pack manifest is incompatible"
|
||||
}
|
||||
$null = Assert-RegularFile (Join-Path $validFov "manifest.json") "Valid-FOV manifest"
|
||||
$null = Assert-RegularFile (Join-Path $validFov "mask.png") "Valid-FOV mask"
|
||||
|
||||
$cacheRoot = Assert-Directory (Join-Path $runtime "cache\perception-p0-models-v1") "Model cache"
|
||||
$torchEnvironment = Assert-Directory (Join-Path $runtime "derived\perception-p0-env-v1") "Torch environment"
|
||||
$transformersEnvironment = Assert-Directory (Join-Path $runtime "derived\perception-p0-transformers4576-v1") "Transformers environment"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$derivedRoot = Join-Path $runtime "derived\evaluation-prelabels"
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-{1}.publish" -f $pack.generation_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
|
||||
try {
|
||||
$dockerArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--shm-size", "2g",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $packRoot) + ":/evaluation-pack:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:rw"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3",
|
||||
("/runner/{0}" -f $runnerName),
|
||||
"--evaluation-pack", "/evaluation-pack",
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--output", "/publish/output",
|
||||
"--cache", "/cache",
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
Write-Output ("PHASE=e2-prelabels-start PACK={0}" -f $pack.generation_id)
|
||||
& docker @dockerArgs
|
||||
Assert-LastExitCode "E2 prelabel generation"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.perception-evaluation-prelabels/v1" -or
|
||||
$result.result_id -notmatch "^evaluation-prelabels-[a-f0-9]{64}$" -or
|
||||
$result.identity.evaluation_pack_id -ne $pack.generation_id
|
||||
) {
|
||||
throw "E2 prelabel result manifest is incompatible"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "An immutable E2 prelabel result with the same identity already exists: $finalRoot"
|
||||
}
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $publishRoot) {
|
||||
Remove-Item -LiteralPath $publishRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$JobRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaselineRunnerPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$QualificationManifest,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ValidFovRoot,
|
||||
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
|
||||
[string]$CalibrationSha256 = "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
|
||||
[string]$CalibrationSlot = "camera_1"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegularFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (-not $item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a regular file"
|
||||
}
|
||||
|
||||
function Assert-Directory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a real directory"
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
$jobDirectory = Assert-Directory (Resolve-Path -LiteralPath $JobRoot).Path "Job root"
|
||||
$runner = Assert-RegularFile (Resolve-Path -LiteralPath $RunnerPath).Path "Qualification runner"
|
||||
$baselineRunner = Assert-RegularFile (Resolve-Path -LiteralPath $BaselineRunnerPath).Path "Baseline runner"
|
||||
$qualification = Assert-RegularFile (Resolve-Path -LiteralPath $QualificationManifest).Path "Qualification manifest"
|
||||
$validFov = Assert-Directory (Resolve-Path -LiteralPath $ValidFovRoot).Path "Valid-FOV root"
|
||||
$null = Assert-RegularFile (Join-Path $validFov "manifest.json") "Valid-FOV manifest"
|
||||
$null = Assert-RegularFile (Join-Path $validFov "mask.png") "Valid-FOV mask"
|
||||
if ((Split-Path $runner -Parent) -ne (Split-Path $baselineRunner -Parent)) {
|
||||
throw "Qualification and baseline runners must share one read-only mount"
|
||||
}
|
||||
$runtime = Assert-Directory (Resolve-Path -LiteralPath $RuntimeRoot).Path "Runtime root"
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if ($job.schema_version -ne "missioncore.compute-job/v1" -or $job.job_id -ne (Split-Path $jobDirectory -Leaf)) {
|
||||
throw "Compute job manifest is incompatible"
|
||||
}
|
||||
if ($CalibrationSha256 -notmatch "^[a-f0-9]{64}$" -or $CalibrationSlot -notmatch "^[A-Za-z0-9._-]+$") {
|
||||
throw "Calibration binding is invalid"
|
||||
}
|
||||
|
||||
$sourceId = [string]$job.input.source_id
|
||||
$epoch = [int]$job.input.codec_epoch
|
||||
$frameCount = [int]$job.input.segment_count
|
||||
$timelineStart = [double]$job.input.timeline.start_seconds
|
||||
$timelineEnd = [double]$job.input.timeline.end_seconds
|
||||
$timelineDuration = $timelineEnd - $timelineStart
|
||||
if ($frameCount -lt 1 -or $timelineDuration -le 0) {
|
||||
throw "Compute job frame/timeline contract is invalid"
|
||||
}
|
||||
$qualificationDocument = Get-Content -LiteralPath $qualification -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$qualificationDocument.schema_version -ne "missioncore.recorded-qualification-slice/v1" -or
|
||||
$qualificationDocument.identity.job_id -ne $job.job_id -or
|
||||
$qualificationDocument.identity.input_sha256 -ne $job.input_sha256
|
||||
) {
|
||||
throw "Qualification manifest is not bound to this job"
|
||||
}
|
||||
Write-Output ("PHASE=inputs-validated QUALIFICATION_FRAMES={0}" -f @($qualificationDocument.frames).Count)
|
||||
|
||||
$epochRoot = Join-Path $jobDirectory ("input\camera\{0}\epoch-{1}" -f $sourceId, $epoch)
|
||||
$epochRoot = Assert-Directory $epochRoot "Camera epoch"
|
||||
$initPath = Assert-RegularFile (Join-Path $epochRoot "init.mp4") "Camera init"
|
||||
$segmentsRoot = Assert-Directory (Join-Path $epochRoot "segments") "Camera segments"
|
||||
$derivedRoot = Join-Path $runtime "derived\qualification"
|
||||
$tmpRoot = Join-Path $runtime "tmp"
|
||||
$cacheRoot = Assert-Directory (Join-Path $runtime "cache\perception-p0-models-v1") "Model cache"
|
||||
$torchEnvironment = Assert-Directory (Join-Path $runtime "derived\perception-p0-env-v1") "Torch environment"
|
||||
$transformersEnvironment = Assert-Directory (Join-Path $runtime "derived\perception-p0-transformers4576-v1") "Transformers environment"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$baselineRunnerName = Split-Path $baselineRunner -Leaf
|
||||
$qualificationRoot = Split-Path $qualification -Parent
|
||||
$qualificationName = Split-Path $qualification -Leaf
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
$null = New-Item -ItemType Directory -Path $tmpRoot -Force
|
||||
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:rw"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3",
|
||||
("/runner/{0}" -f $baselineRunnerName), "preflight",
|
||||
"--job", "/job",
|
||||
"--cache", "/cache"
|
||||
)
|
||||
Write-Output "PHASE=worker-preflight-started"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "Worker preflight"
|
||||
Write-Output "PHASE=worker-preflight-complete"
|
||||
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$workRoot = Join-Path $tmpRoot ("{0}-e1-{1}" -f $job.job_id, $runToken)
|
||||
$framesRoot = Join-Path $workRoot "frames"
|
||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||
$ptsPath = Join-Path $workRoot "pts.json"
|
||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-e1-{1}.publish" -f $job.job_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
Write-Output "PHASE=private-staging-created"
|
||||
|
||||
try {
|
||||
$stream = [IO.File]::Open($streamPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try {
|
||||
$input = [IO.File]::OpenRead($initPath)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
for ($sequence = 1; $sequence -le $frameCount; $sequence++) {
|
||||
$path = Assert-RegularFile (Join-Path $segmentsRoot ("{0}.m4s" -f $sequence)) "Camera segment"
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
if ($sequence % 500 -eq 0 -or $sequence -eq $frameCount) {
|
||||
Write-Output ("PHASE=stream-reconstruction SEGMENTS={0}/{1}" -f $sequence, $frameCount)
|
||||
}
|
||||
}
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
Write-Output "PHASE=frame-extraction-started"
|
||||
& ffmpeg -hide_banner -loglevel fatal -i $streamPath -map 0:v:0 -fps_mode passthrough (Join-Path $framesRoot "frame-%06d.png")
|
||||
Assert-LastExitCode "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 "Camera timestamp probe"
|
||||
|
||||
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||
$ptsDocument = Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json
|
||||
$pts = @($ptsDocument.frames)
|
||||
if ($decodedFrames.Count -ne $frameCount -or $pts.Count -ne $frameCount) {
|
||||
throw "Decoded frame count differs from the compute job"
|
||||
}
|
||||
$firstEpochSeconds = [double]::Parse(
|
||||
([string]$pts[0].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
)
|
||||
$previousEpochSeconds = -1.0
|
||||
$timelineWriter = [IO.StreamWriter]::new($timelinePath, $false, [Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
for ($index = 0; $index -lt $frameCount; $index++) {
|
||||
$epochSeconds = [double]::Parse(
|
||||
([string]$pts[$index].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
) - $firstEpochSeconds
|
||||
if ($epochSeconds -le $previousEpochSeconds -or $epochSeconds -gt ($timelineDuration + 0.001)) {
|
||||
throw "Decoded camera timestamps are not strictly monotonic inside the compute job timeline"
|
||||
}
|
||||
$row = [ordered]@{
|
||||
frame_index = $index
|
||||
epoch_seconds = $epochSeconds
|
||||
session_seconds = $timelineStart + $epochSeconds
|
||||
}
|
||||
$timelineWriter.WriteLine(($row | ConvertTo-Json -Compress))
|
||||
$previousEpochSeconds = $epochSeconds
|
||||
}
|
||||
$timelineWriter.Flush()
|
||||
}
|
||||
finally {
|
||||
$timelineWriter.Dispose()
|
||||
}
|
||||
Write-Output ("PHASE=frame-extraction-complete FRAMES={0}" -f $frameCount)
|
||||
|
||||
$dockerArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--shm-size", "2g",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $framesRoot) + ":/frames:ro"),
|
||||
"-v", ((Convert-ToDockerPath $workRoot) + ":/work:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:rw"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"-v", ((Convert-ToDockerPath $qualificationRoot) + ":/qualification:ro"),
|
||||
"-v", ((Convert-ToDockerPath $validFov) + ":/valid-fov:ro"),
|
||||
"--entrypoint", "python3",
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3",
|
||||
("/runner/{0}" -f $runnerName),
|
||||
"--job", "/job",
|
||||
"--frames", "/frames",
|
||||
"--timeline", "/work/timeline.jsonl",
|
||||
"--qualification", ("/qualification/{0}" -f $qualificationName),
|
||||
"--valid-fov-root", "/valid-fov",
|
||||
"--output", "/publish/output",
|
||||
"--cache", "/cache",
|
||||
"--calibration-sha256", $CalibrationSha256,
|
||||
"--calibration-slot", $CalibrationSlot,
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
& docker @dockerArgs
|
||||
Assert-LastExitCode "Preprocessing qualification"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if (
|
||||
$result.schema_version -ne "missioncore.perception-preprocessing-qualification-result/v1" -or
|
||||
$result.result_id -notmatch "^qualification-result-[a-f0-9]{64}$"
|
||||
) {
|
||||
throw "Qualification result manifest is incompatible"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "An immutable qualification result with the same identity already exists: $finalRoot"
|
||||
}
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workRoot) {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$JobRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunnerPath,
|
||||
|
||||
[string]$RuntimeRoot = "D:\NDC_MISSIONCORE\runtime",
|
||||
|
||||
[string]$CalibrationSha256 = "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
|
||||
[string]$CalibrationSlot = "camera_1"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string]$Operation)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "$Operation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RegularFile {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (-not $item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a regular file"
|
||||
}
|
||||
|
||||
function Assert-Directory {
|
||||
param([string]$Path, [string]$Label)
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if ($item.PSIsContainer -and -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
return $item.FullName
|
||||
}
|
||||
throw "$Label is not a real directory"
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath {
|
||||
param([string]$Path)
|
||||
return $Path.Replace("\", "/")
|
||||
}
|
||||
|
||||
$jobDirectory = Assert-Directory (Resolve-Path -LiteralPath $JobRoot).Path "Job root"
|
||||
$runner = Assert-RegularFile (Resolve-Path -LiteralPath $RunnerPath).Path "Runner"
|
||||
$runtime = Assert-Directory (Resolve-Path -LiteralPath $RuntimeRoot).Path "Runtime root"
|
||||
$job = Get-Content -LiteralPath (Join-Path $jobDirectory "job.json") -Raw | ConvertFrom-Json
|
||||
if ($job.schema_version -ne "missioncore.compute-job/v1" -or $job.job_id -ne (Split-Path $jobDirectory -Leaf)) {
|
||||
throw "Compute job manifest is incompatible"
|
||||
}
|
||||
if ($CalibrationSha256 -notmatch "^[a-f0-9]{64}$" -or $CalibrationSlot -notmatch "^[A-Za-z0-9._-]+$") {
|
||||
throw "Calibration binding is invalid"
|
||||
}
|
||||
Write-Output "PHASE=job-manifest-validated"
|
||||
|
||||
$sourceId = [string]$job.input.source_id
|
||||
$epoch = [int]$job.input.codec_epoch
|
||||
$frameCount = [int]$job.input.segment_count
|
||||
$timelineStart = [double]$job.input.timeline.start_seconds
|
||||
$timelineEnd = [double]$job.input.timeline.end_seconds
|
||||
$timelineDuration = $timelineEnd - $timelineStart
|
||||
if ($frameCount -lt 1 -or $timelineDuration -le 0) {
|
||||
throw "Compute job frame/timeline contract is invalid"
|
||||
}
|
||||
|
||||
$epochRoot = Join-Path $jobDirectory ("input\camera\{0}\epoch-{1}" -f $sourceId, $epoch)
|
||||
$epochRoot = Assert-Directory $epochRoot "Camera epoch"
|
||||
$initPath = Assert-RegularFile (Join-Path $epochRoot "init.mp4") "Camera init"
|
||||
$segmentsRoot = Assert-Directory (Join-Path $epochRoot "segments") "Camera segments"
|
||||
$derivedRoot = Join-Path $runtime "derived"
|
||||
$tmpRoot = Join-Path $runtime "tmp"
|
||||
$cacheRoot = Assert-Directory (Join-Path $runtime "cache\perception-p0-models-v1") "Model cache"
|
||||
$torchEnvironment = Assert-Directory (Join-Path $derivedRoot "perception-p0-env-v1") "Torch environment"
|
||||
$transformersEnvironment = Assert-Directory (Join-Path $derivedRoot "perception-p0-transformers4576-v1") "Transformers environment"
|
||||
$runnerRoot = Split-Path $runner -Parent
|
||||
$runnerName = Split-Path $runner -Leaf
|
||||
$null = New-Item -ItemType Directory -Path $derivedRoot -Force
|
||||
$null = New-Item -ItemType Directory -Path $tmpRoot -Force
|
||||
|
||||
$preflightArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:rw"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3",
|
||||
("/runner/{0}" -f $runnerName), "preflight",
|
||||
"--job", "/job",
|
||||
"--cache", "/cache"
|
||||
)
|
||||
Write-Output "PHASE=worker-preflight-started"
|
||||
& docker @preflightArgs
|
||||
Assert-LastExitCode "Worker preflight"
|
||||
Write-Output "PHASE=worker-preflight-complete"
|
||||
|
||||
$runToken = [Guid]::NewGuid().ToString("N")
|
||||
$workRoot = Join-Path $tmpRoot ("{0}-panoptic-{1}" -f $job.job_id, $runToken)
|
||||
$framesRoot = Join-Path $workRoot "frames"
|
||||
$streamPath = Join-Path $workRoot "camera.mp4"
|
||||
$ptsPath = Join-Path $workRoot "pts.json"
|
||||
$timelinePath = Join-Path $workRoot "timeline.jsonl"
|
||||
$publishRoot = Join-Path $derivedRoot (".{0}-panoptic-{1}.publish" -f $job.job_id, $runToken)
|
||||
$stagingRoot = Join-Path $publishRoot "output"
|
||||
$null = New-Item -ItemType Directory -Path $framesRoot
|
||||
$null = New-Item -ItemType Directory -Path $publishRoot
|
||||
$totalWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Output "PHASE=private-staging-created"
|
||||
|
||||
try {
|
||||
$stream = [IO.File]::Open($streamPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try {
|
||||
foreach ($path in @($initPath)) {
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
}
|
||||
for ($sequence = 1; $sequence -le $frameCount; $sequence++) {
|
||||
$path = Assert-RegularFile (Join-Path $segmentsRoot ("{0}.m4s" -f $sequence)) "Camera segment"
|
||||
$input = [IO.File]::OpenRead($path)
|
||||
try {
|
||||
$input.CopyTo($stream)
|
||||
}
|
||||
finally {
|
||||
$input.Dispose()
|
||||
}
|
||||
if ($sequence % 500 -eq 0 -or $sequence -eq $frameCount) {
|
||||
Write-Output ("PHASE=stream-reconstruction SEGMENTS={0}/{1}" -f $sequence, $frameCount)
|
||||
}
|
||||
}
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
$extractWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Output "PHASE=frame-extraction-started"
|
||||
# This archive contains valid strictly-increasing PTS but image2 rounds DTS
|
||||
# to its own time base and reports harmless duplicate-DTS diagnostics. Exact
|
||||
# frame-count and JSON timestamp checks below are the admission boundary.
|
||||
& ffmpeg -hide_banner -loglevel fatal -i $streamPath -map 0:v:0 -fps_mode passthrough (Join-Path $framesRoot "frame-%06d.png")
|
||||
Assert-LastExitCode "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 "Camera timestamp probe"
|
||||
|
||||
$decodedFrames = @(Get-ChildItem -LiteralPath $framesRoot -File -Filter "frame-*.png")
|
||||
$ptsDocument = Get-Content -LiteralPath $ptsPath -Raw | ConvertFrom-Json
|
||||
$pts = @($ptsDocument.frames)
|
||||
if ($decodedFrames.Count -ne $frameCount -or $pts.Count -ne $frameCount) {
|
||||
throw "Decoded frame count differs from the compute job"
|
||||
}
|
||||
$firstEpochSeconds = [double]::Parse(
|
||||
([string]$pts[0].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
)
|
||||
$previousEpochSeconds = -1.0
|
||||
$timelineWriter = [IO.StreamWriter]::new($timelinePath, $false, [Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
for ($index = 0; $index -lt $frameCount; $index++) {
|
||||
$epochSeconds = [double]::Parse(
|
||||
([string]$pts[$index].best_effort_timestamp_time).Trim(),
|
||||
[Globalization.CultureInfo]::InvariantCulture
|
||||
) - $firstEpochSeconds
|
||||
if ($epochSeconds -le $previousEpochSeconds -or $epochSeconds -gt ($timelineDuration + 0.001)) {
|
||||
throw "Decoded camera timestamps are not strictly monotonic inside the compute job timeline"
|
||||
}
|
||||
$sessionSeconds = $timelineStart + $epochSeconds
|
||||
$row = [ordered]@{
|
||||
frame_index = $index
|
||||
epoch_seconds = $epochSeconds
|
||||
session_seconds = $sessionSeconds
|
||||
}
|
||||
$timelineWriter.WriteLine(($row | ConvertTo-Json -Compress))
|
||||
$previousEpochSeconds = $epochSeconds
|
||||
}
|
||||
$timelineWriter.Flush()
|
||||
}
|
||||
finally {
|
||||
$timelineWriter.Dispose()
|
||||
}
|
||||
$extractWatch.Stop()
|
||||
Write-Output ("PHASE=frame-extraction-complete FRAMES={0}" -f $frameCount)
|
||||
|
||||
$dockerArgs = @(
|
||||
"run", "--rm", "--gpus", "all", "--network", "none",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"--shm-size", "2g",
|
||||
"-e", "PYTHONPATH=/opt/transformers:/opt/env",
|
||||
"-e", "TORCH_HOME=/cache/torch",
|
||||
"-e", "HF_HOME=/cache/huggingface",
|
||||
"-e", "HF_HUB_OFFLINE=1",
|
||||
"-e", "TRANSFORMERS_OFFLINE=1",
|
||||
"-v", ((Convert-ToDockerPath $jobDirectory) + ":/job:ro"),
|
||||
"-v", ((Convert-ToDockerPath $framesRoot) + ":/frames:ro"),
|
||||
"-v", ((Convert-ToDockerPath $workRoot) + ":/work:ro"),
|
||||
"-v", ((Convert-ToDockerPath $publishRoot) + ":/publish:rw"),
|
||||
"-v", ((Convert-ToDockerPath $cacheRoot) + ":/cache:rw"),
|
||||
"-v", ((Convert-ToDockerPath $torchEnvironment) + ":/opt/env:ro"),
|
||||
"-v", ((Convert-ToDockerPath $transformersEnvironment) + ":/opt/transformers:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3",
|
||||
("/runner/{0}" -f $runnerName), "run",
|
||||
"--job", "/job",
|
||||
"--frames", "/frames",
|
||||
"--timeline", "/work/timeline.jsonl",
|
||||
"--output", "/publish/output",
|
||||
"--cache", "/cache",
|
||||
"--calibration-sha256", $CalibrationSha256,
|
||||
"--calibration-slot", $CalibrationSlot,
|
||||
"--telemetry-interval-seconds", "1"
|
||||
)
|
||||
& docker @dockerArgs
|
||||
Assert-LastExitCode "Panoptic inference"
|
||||
Write-Output "PHASE=panoptic-inference-complete"
|
||||
|
||||
$videoPath = Join-Path $stagingRoot "perception.mp4"
|
||||
$encodeWatch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$averageFps = $frameCount / $timelineDuration
|
||||
$fpsText = $averageFps.ToString("0.#########", [Globalization.CultureInfo]::InvariantCulture)
|
||||
$durationText = $timelineDuration.ToString("0.#########", [Globalization.CultureInfo]::InvariantCulture)
|
||||
& ffmpeg -hide_banner -loglevel error -framerate $fpsText -i (Join-Path $stagingRoot "overlay-frames\frame-%06d.png") -t $durationText -c:v h264_nvenc -preset p4 -tune hq -rc vbr -cq 21 -b:v 0 -pix_fmt yuv420p -movflags +faststart $videoPath
|
||||
Assert-LastExitCode "Panoptic video encoding"
|
||||
$encodeWatch.Stop()
|
||||
Write-Output "PHASE=video-encoding-complete"
|
||||
|
||||
$masksPath = Join-Path $stagingRoot "masks.tar.gz"
|
||||
& tar.exe -czf $masksPath -C $stagingRoot instance-masks semantic-masks
|
||||
Assert-LastExitCode "Mask archive publication"
|
||||
|
||||
$totalWatch.Stop()
|
||||
$finalizeArgs = @(
|
||||
"run", "--rm", "--network", "none",
|
||||
"--security-opt", "no-new-privileges:true", "--cap-drop", "ALL",
|
||||
"-v", ((Convert-ToDockerPath $stagingRoot) + ":/output:rw"),
|
||||
"-v", ((Convert-ToDockerPath $runnerRoot) + ":/runner:ro"),
|
||||
"--entrypoint", "python3",
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3",
|
||||
("/runner/{0}" -f $runnerName), "finalize",
|
||||
"--output", "/output",
|
||||
"--video", "/output/perception.mp4",
|
||||
"--masks", "/output/masks.tar.gz",
|
||||
"--extract-seconds", $extractWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--encode-seconds", $encodeWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--wall-seconds", $totalWatch.Elapsed.TotalSeconds.ToString("0.######", [Globalization.CultureInfo]::InvariantCulture),
|
||||
"--encoder", "ffmpeg-h264_nvenc-p4-cq21-yuv420p-faststart"
|
||||
)
|
||||
& docker @finalizeArgs
|
||||
Assert-LastExitCode "Result finalization"
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $stagingRoot "result.json") -Raw | ConvertFrom-Json
|
||||
if ($result.schema_version -ne "missioncore.recorded-perception-result/v2" -or $result.result_id -notmatch "^result-[a-f0-9]{64}$") {
|
||||
throw "Final result manifest is incompatible"
|
||||
}
|
||||
$finalRoot = Join-Path $derivedRoot ([string]$result.result_id)
|
||||
if (Test-Path -LiteralPath $finalRoot) {
|
||||
throw "An immutable result with the same identity already exists: $finalRoot"
|
||||
}
|
||||
|
||||
foreach ($temporaryChild in @("overlay-frames", "instance-masks", "semantic-masks")) {
|
||||
$temporaryPath = Join-Path $stagingRoot $temporaryChild
|
||||
if (Test-Path -LiteralPath $temporaryPath) {
|
||||
Remove-Item -LiteralPath $temporaryPath -Recurse -Force
|
||||
}
|
||||
}
|
||||
Move-Item -LiteralPath $stagingRoot -Destination $finalRoot
|
||||
Remove-Item -LiteralPath $publishRoot -Force
|
||||
Write-Output ("RESULT_ROOT={0}" -f $finalRoot)
|
||||
Write-Output ("RESULT_ID={0}" -f $result.result_id)
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $workRoot) {
|
||||
Remove-Item -LiteralPath $workRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Mission Core CVAT D-only profile
|
||||
|
||||
This profile installs the annotation control plane in a dedicated WSL2
|
||||
distribution named `MissionCore-CVAT`. Its VHDX, Docker image store, CVAT
|
||||
source, persistent volumes, reports, and imported datasets live below
|
||||
`D:\NDC_MISSIONCORE`. It does not use the Docker Desktop image store that backs
|
||||
the Triton, Frigate, and Ollama containers.
|
||||
|
||||
Pinned inputs:
|
||||
|
||||
- Ubuntu 24.04.4 WSL AMD64 image, SHA-256
|
||||
`9b2f7730dc68227dd04a9f3e5eab86ad85caf556b8606ad94f1f29ff5c4fd3f5`
|
||||
- CVAT `v2.70.0`
|
||||
- D free-space floor: `360 GiB`
|
||||
- WSL VHD logical ceiling: `32 GB` (sparse allocation)
|
||||
|
||||
The official CVAT Compose topology is retained. The override only replaces its
|
||||
named volumes with explicit bind-backed directories inside the D-hosted WSL
|
||||
VHD. Images are pulled serially and the D free-space floor is checked before and
|
||||
after every image.
|
||||
|
||||
Run from the Windows host:
|
||||
|
||||
```powershell
|
||||
wsl -d MissionCore-CVAT -u root -- bash /mnt/d/NDC_MISSIONCORE/workspace/mission-core-compute/cvat/provision_cvat_wsl.sh
|
||||
wsl -d MissionCore-CVAT -u root -- bash /mnt/d/NDC_MISSIONCORE/workspace/mission-core-compute/cvat/ensure_cvat_admin.sh
|
||||
wsl -d MissionCore-CVAT -u root -- bash /mnt/d/NDC_MISSIONCORE/workspace/mission-core-compute/cvat/import_e2_workspace.sh
|
||||
```
|
||||
|
||||
After a Windows reboot, start the existing deployment without pulling images or
|
||||
re-provisioning it:
|
||||
|
||||
```powershell
|
||||
& D:\NDC_MISSIONCORE\workspace\mission-core-compute\cvat\Start-Cvat.ps1
|
||||
```
|
||||
|
||||
The launcher checks the `360 GiB` D-drive floor, starts the pinned Compose
|
||||
topology in the D-hosted WSL distribution, waits for the API, and keeps the WSL
|
||||
runtime alive. It writes startup logs only below
|
||||
`D:\NDC_MISSIONCORE\runtime\annotation\cvat\logs`.
|
||||
|
||||
From the Mission Core repository on the Mac, start CVAT if needed, discover the
|
||||
current WSL address, and open the SSH tunnel without a hard-coded IP:
|
||||
|
||||
```bash
|
||||
bash experiments/perception/worker/cvat/open_cvat_tunnel.sh
|
||||
```
|
||||
|
||||
Keep that terminal open and use `http://localhost:18080`. The same SSH session
|
||||
keeps the WSL runtime alive. Pass `--background` when detached runtime and
|
||||
tunnel sessions are preferred; rerun the helper after a Mac or Windows reboot.
|
||||
|
||||
The generated administrator password is stored only in
|
||||
`D:\NDC_MISSIONCORE\secrets\cvat\admin.env`; it is not printed by the script or
|
||||
committed to Git.
|
||||
|
||||
Traefik binds inside the dedicated WSL environment to `127.0.0.1:8080` and
|
||||
`127.0.0.1:8090`. It also publishes container port `8080` as WSL-internal port
|
||||
`18080` so the existing Windows SSH service can forward it without relying on
|
||||
Windows-to-WSL localhost forwarding. Remote review still uses the exact SSH
|
||||
host alias and a local forward; this profile does not add routes, Windows port
|
||||
proxies, DNS changes, or firewall rules.
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$TimeoutSeconds = 180
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Distro = 'MissionCore-CVAT'
|
||||
$Root = 'D:\NDC_MISSIONCORE'
|
||||
$RuntimeRoot = Join-Path $Root 'runtime\annotation\cvat'
|
||||
$LogRoot = Join-Path $RuntimeRoot 'logs'
|
||||
$LinuxStartScript = '/mnt/d/NDC_MISSIONCORE/workspace/mission-core-compute/cvat/start_cvat_runtime.sh'
|
||||
$FreeGiBFloor = 360
|
||||
|
||||
$drive = Get-PSDrive -Name D
|
||||
$freeGiB = [math]::Floor($drive.Free / 1GB)
|
||||
if ($freeGiB -lt $FreeGiBFloor) {
|
||||
throw "D: free-space floor crossed ($freeGiB GiB free; $FreeGiBFloor GiB required)"
|
||||
}
|
||||
|
||||
try {
|
||||
$about = Invoke-RestMethod -Uri 'http://localhost:8080/api/server/about' -TimeoutSec 3
|
||||
if ($about.version -eq '2.70.0') {
|
||||
Write-Output "CVAT already ready version=$($about.version) free_gib=$freeGiB"
|
||||
exit 0
|
||||
}
|
||||
} catch {
|
||||
# A stopped WSL distribution is the normal condition after a Windows reboot.
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $LogRoot -Force | Out-Null
|
||||
$timestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
|
||||
$stdoutPath = Join-Path $LogRoot "start-$timestamp.stdout.log"
|
||||
$stderrPath = Join-Path $LogRoot "start-$timestamp.stderr.log"
|
||||
$arguments = @(
|
||||
'-d', $Distro,
|
||||
'-u', 'root',
|
||||
'--', 'bash', $LinuxStartScript, '--keepalive'
|
||||
)
|
||||
|
||||
Start-Process -FilePath 'wsl.exe' -ArgumentList $arguments -WindowStyle Hidden `
|
||||
-RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
do {
|
||||
Start-Sleep -Seconds 2
|
||||
try {
|
||||
$about = Invoke-RestMethod -Uri 'http://localhost:8080/api/server/about' -TimeoutSec 3
|
||||
if ($about.version -eq '2.70.0') {
|
||||
Write-Output "CVAT ready version=$($about.version) free_gib=$freeGiB"
|
||||
Write-Output "startup_log=$stdoutPath"
|
||||
exit 0
|
||||
}
|
||||
} catch {
|
||||
# Continue until the complete CVAT Compose topology is ready.
|
||||
}
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
|
||||
throw "CVAT did not become ready within $TimeoutSeconds seconds; inspect $stderrPath"
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
services:
|
||||
traefik:
|
||||
ports: !override
|
||||
- 127.0.0.1:8080:8080
|
||||
- 127.0.0.1:8090:8090
|
||||
- 0.0.0.0:18080:8080
|
||||
|
||||
volumes:
|
||||
cvat_db:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /srv/mission-core-cvat/volumes/cvat_db
|
||||
cvat_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /srv/mission-core-cvat/volumes/cvat_data
|
||||
cvat_keys:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /srv/mission-core-cvat/volumes/cvat_keys
|
||||
cvat_logs:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /srv/mission-core-cvat/volumes/cvat_logs
|
||||
cvat_inmem_db:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /srv/mission-core-cvat/volumes/cvat_inmem_db
|
||||
cvat_events_db:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /srv/mission-core-cvat/volumes/cvat_events_db
|
||||
cvat_cache_db:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /srv/mission-core-cvat/volumes/cvat_cache_db
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
readonly SECRET_ROOT="/mnt/d/NDC_MISSIONCORE/secrets/cvat"
|
||||
readonly SECRET_FILE="${SECRET_ROOT}/admin.env"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "ensure_cvat_admin.sh must run as root" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
install -d -m 0700 "${SECRET_ROOT}"
|
||||
if [[ ! -f "${SECRET_FILE}" ]]; then
|
||||
umask 077
|
||||
password="$(openssl rand -hex 24)"
|
||||
{
|
||||
printf 'CVAT_ADMIN_USERNAME=missioncore\n'
|
||||
printf 'CVAT_ADMIN_EMAIL=missioncore@local.invalid\n'
|
||||
printf 'CVAT_ADMIN_PASSWORD=%s\n' "${password}"
|
||||
} >"${SECRET_FILE}"
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${SECRET_FILE}"
|
||||
set +a
|
||||
|
||||
docker exec \
|
||||
-e "CVAT_ADMIN_USERNAME=${CVAT_ADMIN_USERNAME}" \
|
||||
-e "CVAT_ADMIN_EMAIL=${CVAT_ADMIN_EMAIL}" \
|
||||
-e "CVAT_ADMIN_PASSWORD=${CVAT_ADMIN_PASSWORD}" \
|
||||
cvat_server \
|
||||
python3 /home/django/manage.py shell -c \
|
||||
'import os; from django.contrib.auth import get_user_model; User = get_user_model(); user, _ = User.objects.get_or_create(username=os.environ["CVAT_ADMIN_USERNAME"]); user.email = os.environ["CVAT_ADMIN_EMAIL"]; user.is_staff = True; user.is_superuser = True; user.set_password(os.environ["CVAT_ADMIN_PASSWORD"]); user.save()'
|
||||
|
||||
printf 'admin_ready=true\nusername=%s\nsecret_file=%s\n' \
|
||||
"${CVAT_ADMIN_USERNAME}" "${SECRET_FILE}"
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cvat_sdk import make_client, models
|
||||
from cvat_sdk.api_client.exceptions import ServiceException
|
||||
from cvat_sdk.core.proxies.tasks import ResourceType
|
||||
|
||||
WORKSPACE_ID = (
|
||||
"annotation-workspace-"
|
||||
"9a950d1c37d56dc12cc285b13c5addd7795285879cbcb1fbb2d5811c3c69821a"
|
||||
)
|
||||
EVALUATION_PACK_ID = (
|
||||
"evaluation-pack-"
|
||||
"7a983bba75d46c7c260252cb2d461e1384dcb92cda9e164397e841e6ebb37789"
|
||||
)
|
||||
EXPECTED_FRAME_COUNT = 64
|
||||
EXPECTED_INSTANCE_COUNT = 775
|
||||
BACKGROUND_LABEL = {
|
||||
"name": "background",
|
||||
"color": "#000000",
|
||||
"attributes": [],
|
||||
}
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _label_specs(raw_labels: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
labels = [
|
||||
{
|
||||
"name": item["name"],
|
||||
"color": item["color"],
|
||||
"attributes": [],
|
||||
}
|
||||
for item in raw_labels
|
||||
]
|
||||
if not any(label["name"] == BACKGROUND_LABEL["name"] for label in labels):
|
||||
labels.insert(0, dict(BACKGROUND_LABEL))
|
||||
return labels
|
||||
|
||||
|
||||
def _annotation_counts(task: Any) -> dict[str, int]:
|
||||
annotations = task.get_annotations()
|
||||
return {
|
||||
"shape_count": len(annotations.shapes),
|
||||
"tag_count": len(annotations.tags),
|
||||
"track_count": len(annotations.tracks),
|
||||
}
|
||||
|
||||
|
||||
def _list_tasks_when_ready(client: Any, *, attempts: int = 60) -> list[Any]:
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return list(client.tasks.list())
|
||||
except ServiceException as error:
|
||||
if error.status not in {500, 502, 503} or attempt == attempts:
|
||||
raise
|
||||
time.sleep(2)
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def _task_record(task: Any, *, disposition: str, expected_labels: list[str]) -> dict[str, Any]:
|
||||
task.fetch()
|
||||
task_labels = list(task.get_labels())
|
||||
actual_labels = sorted(label.name for label in task_labels)
|
||||
if task.size != EXPECTED_FRAME_COUNT:
|
||||
raise RuntimeError(
|
||||
f"Task {task.id} has {task.size} frames, expected {EXPECTED_FRAME_COUNT}"
|
||||
)
|
||||
if actual_labels != sorted(expected_labels):
|
||||
raise RuntimeError(
|
||||
f"Task {task.id} labels differ: actual={actual_labels!r} "
|
||||
f"expected={sorted(expected_labels)!r}"
|
||||
)
|
||||
annotation_counts = _annotation_counts(task)
|
||||
label_names_by_id = {label.id: label.name for label in task_labels}
|
||||
annotations = task.get_annotations()
|
||||
shape_counts_by_label = dict(
|
||||
sorted(
|
||||
Counter(
|
||||
label_names_by_id.get(shape.label_id, f"unknown:{shape.label_id}")
|
||||
for shape in annotations.shapes
|
||||
).items()
|
||||
)
|
||||
)
|
||||
return {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"disposition": disposition,
|
||||
"frame_count": task.size,
|
||||
"label_names": actual_labels,
|
||||
**annotation_counts,
|
||||
"shape_counts_by_label": shape_counts_by_label,
|
||||
"url_path": f"/tasks/{task.id}",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_task(
|
||||
client: Any,
|
||||
*,
|
||||
name: str,
|
||||
labels: list[dict[str, Any]],
|
||||
images_path: Path,
|
||||
annotation_path: Path,
|
||||
annotation_format: str,
|
||||
expected_shape_count: int | None,
|
||||
) -> dict[str, Any]:
|
||||
matches = [task for task in _list_tasks_when_ready(client) if task.name == name]
|
||||
if len(matches) > 1:
|
||||
raise RuntimeError(f"Multiple CVAT tasks have the reserved name {name!r}")
|
||||
|
||||
expected_labels = [label["name"] for label in labels]
|
||||
if matches:
|
||||
task = matches[0]
|
||||
task.fetch()
|
||||
actual_labels = sorted(label.name for label in task.get_labels())
|
||||
expected_without_background = sorted(
|
||||
label for label in expected_labels if label != BACKGROUND_LABEL["name"]
|
||||
)
|
||||
disposition = "reused"
|
||||
if (
|
||||
actual_labels == expected_without_background
|
||||
and BACKGROUND_LABEL["name"] in expected_labels
|
||||
):
|
||||
task.update(
|
||||
models.PatchedTaskWriteRequest(
|
||||
labels=[models.PatchedLabelRequest(**BACKGROUND_LABEL)]
|
||||
)
|
||||
)
|
||||
disposition = "reused-and-background-label-added"
|
||||
|
||||
record = _task_record(
|
||||
task,
|
||||
disposition=disposition,
|
||||
expected_labels=expected_labels,
|
||||
)
|
||||
if record["shape_count"] == 0:
|
||||
task.import_annotations(annotation_format, annotation_path)
|
||||
record = _task_record(
|
||||
task,
|
||||
disposition=f"{disposition}-and-annotations-imported",
|
||||
expected_labels=expected_labels,
|
||||
)
|
||||
if expected_shape_count is not None and record["shape_count"] != expected_shape_count:
|
||||
raise RuntimeError(
|
||||
f"Task {task.id} has {record['shape_count']} shapes, "
|
||||
f"expected {expected_shape_count}"
|
||||
)
|
||||
return record
|
||||
|
||||
task = client.tasks.create_from_data(
|
||||
spec=models.TaskWriteRequest(
|
||||
name=name,
|
||||
labels=labels,
|
||||
segment_size=EXPECTED_FRAME_COUNT,
|
||||
overlap=0,
|
||||
),
|
||||
resources=[images_path],
|
||||
resource_type=ResourceType.LOCAL,
|
||||
data_params={
|
||||
"image_quality": 100,
|
||||
"sorting_method": "lexicographical",
|
||||
"use_cache": False,
|
||||
},
|
||||
annotation_path=annotation_path,
|
||||
annotation_format=annotation_format,
|
||||
status_check_period=2,
|
||||
)
|
||||
record = _task_record(task, disposition="created", expected_labels=expected_labels)
|
||||
if expected_shape_count is not None and record["shape_count"] != expected_shape_count:
|
||||
raise RuntimeError(
|
||||
f"Task {task.id} has {record['shape_count']} shapes, "
|
||||
f"expected {expected_shape_count}"
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--server", required=True)
|
||||
parser.add_argument("--username", required=True)
|
||||
parser.add_argument("--password-env", required=True)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
password = os.environ.get(args.password_env)
|
||||
if not password:
|
||||
raise RuntimeError(f"Password environment variable {args.password_env!r} is empty")
|
||||
|
||||
workspace = args.workspace.resolve()
|
||||
if workspace.name != WORKSPACE_ID:
|
||||
raise RuntimeError(f"Unexpected annotation workspace: {workspace}")
|
||||
|
||||
manifest_path = workspace / "manifest.json"
|
||||
labels_path = workspace / "cvat" / "labels.json"
|
||||
images_path = workspace / "cvat" / "images.zip"
|
||||
instance_path = workspace / "cvat" / "instance-coco.zip"
|
||||
semantic_path = workspace / "cvat" / "semantic-mask.zip"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
labels = json.loads(labels_path.read_text(encoding="utf-8"))
|
||||
|
||||
if manifest["ground_truth"] is not False:
|
||||
raise RuntimeError("LAB E2 import must remain an unreviewed model draft")
|
||||
if manifest["identity"]["frame_count"] != EXPECTED_FRAME_COUNT:
|
||||
raise RuntimeError("Unexpected LAB E2 frame count")
|
||||
if manifest["identity"]["draft_instance_count"] != EXPECTED_INSTANCE_COUNT:
|
||||
raise RuntimeError("Unexpected LAB E2 instance count")
|
||||
|
||||
task_specs = [
|
||||
{
|
||||
"name": "LAB E2 | K1 | instance prelabels | pack 7a983bba",
|
||||
"labels": _label_specs(labels["instance_task"]),
|
||||
"annotation_path": instance_path,
|
||||
"annotation_format": "COCO 1.0",
|
||||
"expected_shape_count": EXPECTED_INSTANCE_COUNT,
|
||||
},
|
||||
{
|
||||
"name": "LAB E2 | K1 | dense semantic prelabels | pack 7a983bba",
|
||||
"labels": _label_specs(labels["semantic_task"]),
|
||||
"annotation_path": semantic_path,
|
||||
"annotation_format": "Segmentation mask 1.1",
|
||||
"expected_shape_count": None,
|
||||
},
|
||||
]
|
||||
|
||||
with make_client(args.server, credentials=(args.username, password)) as client:
|
||||
client.check_server_version(fail_if_unsupported=True)
|
||||
task_records = [
|
||||
_ensure_task(
|
||||
client,
|
||||
name=task_spec["name"],
|
||||
labels=task_spec["labels"],
|
||||
images_path=images_path,
|
||||
annotation_path=task_spec["annotation_path"],
|
||||
annotation_format=task_spec["annotation_format"],
|
||||
expected_shape_count=task_spec["expected_shape_count"],
|
||||
)
|
||||
for task_spec in task_specs
|
||||
]
|
||||
|
||||
report = {
|
||||
"schema_version": "missioncore.lab-e2-cvat-import/v1",
|
||||
"created_at_utc": datetime.now(UTC).isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"server": args.server,
|
||||
"cvat_version": "v2.70.0",
|
||||
"workspace_id": WORKSPACE_ID,
|
||||
"evaluation_pack_id": EVALUATION_PACK_ID,
|
||||
"workspace_manifest_sha256": _sha256(manifest_path),
|
||||
"ground_truth": False,
|
||||
"inputs": {
|
||||
"images_zip_sha256": _sha256(images_path),
|
||||
"instance_coco_zip_sha256": _sha256(instance_path),
|
||||
"semantic_mask_zip_sha256": _sha256(semantic_path),
|
||||
},
|
||||
"tasks": task_records,
|
||||
"next_gate": (
|
||||
"two-pass human review and reviewed export; "
|
||||
"do not treat drafts as accuracy evidence"
|
||||
),
|
||||
}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(report, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
readonly FREE_GIB_FLOOR="${FREE_GIB_FLOOR:-360}"
|
||||
readonly WINDOWS_ROOT="/mnt/d/NDC_MISSIONCORE"
|
||||
readonly COMPUTE_ROOT="${WINDOWS_ROOT}/workspace/mission-core-compute"
|
||||
readonly TOOL_ROOT="/srv/mission-core-cvat/tools/e2-import"
|
||||
readonly SECRET_FILE="${WINDOWS_ROOT}/secrets/cvat/admin.env"
|
||||
readonly WORKSPACE_ROOT="${WINDOWS_ROOT}/runtime/annotation/imports/LAB-E2/annotation-workspace-9a950d1c37d56dc12cc285b13c5addd7795285879cbcb1fbb2d5811c3c69821a"
|
||||
readonly REPORT_ROOT="${WINDOWS_ROOT}/runtime/annotation/cvat/reports"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "import_e2_workspace.sh must run as root" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
free_gib() {
|
||||
df -BG --output=avail "${WINDOWS_ROOT}" | tail -n 1 | tr -dc '0-9'
|
||||
}
|
||||
|
||||
guard_disk() {
|
||||
local stage="$1"
|
||||
local free
|
||||
free="$(free_gib)"
|
||||
printf 'disk_guard stage=%s free_gib=%s floor_gib=%s\n' \
|
||||
"${stage}" "${free}" "${FREE_GIB_FLOOR}"
|
||||
if (( free < FREE_GIB_FLOOR )); then
|
||||
echo "D: free-space floor crossed; refusing further writes" >&2
|
||||
exit 3
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_cvat() {
|
||||
local attempt http_code
|
||||
for attempt in $(seq 1 60); do
|
||||
http_code="$(
|
||||
curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
"http://localhost:8080/api/server/about" || true
|
||||
)"
|
||||
if [[ "${http_code}" == "200" ]]; then
|
||||
http_code="$(
|
||||
curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
"http://localhost:8080/api/tasks" || true
|
||||
)"
|
||||
if [[ "${http_code}" == "200" || "${http_code}" == "401" || "${http_code}" == "403" ]]; then
|
||||
printf 'cvat_ready attempt=%s tasks_http=%s\n' "${attempt}" "${http_code}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "CVAT API did not become ready within 120 seconds" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
test -f "${SECRET_FILE}"
|
||||
test -f "${WORKSPACE_ROOT}/manifest.json"
|
||||
test -f "${COMPUTE_ROOT}/cvat/import_e2_workspace.py"
|
||||
guard_disk preflight
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y python3-venv
|
||||
install -d -m 0750 "${TOOL_ROOT}" "${REPORT_ROOT}"
|
||||
|
||||
if [[ ! -x "${TOOL_ROOT}/venv/bin/python" ]]; then
|
||||
python3 -m venv "${TOOL_ROOT}/venv"
|
||||
"${TOOL_ROOT}/venv/bin/pip" install --disable-pip-version-check \
|
||||
"cvat-sdk==2.70.0"
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${SECRET_FILE}"
|
||||
set +a
|
||||
|
||||
wait_for_cvat
|
||||
report="${REPORT_ROOT}/lab-e2-cvat-import-$(date -u +%Y%m%dT%H%M%SZ).json"
|
||||
"${TOOL_ROOT}/venv/bin/python" "${COMPUTE_ROOT}/cvat/import_e2_workspace.py" \
|
||||
--server "http://localhost:8080" \
|
||||
--username "${CVAT_ADMIN_USERNAME}" \
|
||||
--password-env CVAT_ADMIN_PASSWORD \
|
||||
--workspace "${WORKSPACE_ROOT}" \
|
||||
--report "${report}"
|
||||
|
||||
guard_disk imported
|
||||
printf 'import_report=%s\n' "${report}"
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from cvat_sdk import make_client
|
||||
from import_e2_workspace import _ensure_task, _label_specs, _sha256
|
||||
|
||||
WORKSPACE_SCHEMA = "missioncore.lab-e3-cvat-review-workspace/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.lab-e3-cvat-review-identity/v1"
|
||||
EXPECTED_PACK_ID = (
|
||||
"evaluation-pack-"
|
||||
"7a983bba75d46c7c260252cb2d461e1384dcb92cda9e164397e841e6ebb37789"
|
||||
)
|
||||
EXPECTED_RESULT_ID = (
|
||||
"e3-segmentation-"
|
||||
"01bd497c44c2b940add145ec784d3418010327bce0baddd4420b0925317e8a16"
|
||||
)
|
||||
EXPECTED_FRAMES = 64
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _valid_sha256(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) == 64
|
||||
and all(character in "0123456789abcdef" for character in value)
|
||||
)
|
||||
|
||||
|
||||
def _read_object(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError(f"JSON root is not an object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_artifact(root: Path, encoded: object) -> Path:
|
||||
if not isinstance(encoded, str):
|
||||
raise RuntimeError("artifact path is not a string")
|
||||
relative = PurePosixPath(encoded)
|
||||
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
||||
raise RuntimeError("artifact path is unsafe")
|
||||
path = root.joinpath(*relative.parts).resolve(strict=True)
|
||||
if not path.is_file() or not path.is_relative_to(root):
|
||||
raise RuntimeError("artifact path escaped its root")
|
||||
return path
|
||||
|
||||
|
||||
def _workspace(root: Path, images_zip: Path) -> tuple[dict[str, Any], dict[str, Path]]:
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest = _read_object(manifest_path)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != WORKSPACE_SCHEMA
|
||||
or manifest.get("ground_truth") is not False
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not _valid_sha256(identity_sha256)
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or manifest.get("workspace_id") != f"e3-cvat-review-{identity_sha256}"
|
||||
or root.name != manifest.get("workspace_id")
|
||||
or identity.get("evaluation_pack_id") != EXPECTED_PACK_ID
|
||||
or identity.get("e3_result_id") != EXPECTED_RESULT_ID
|
||||
or identity.get("frame_count") != EXPECTED_FRAMES
|
||||
or _sha256(images_zip) != identity.get("images_zip_sha256")
|
||||
):
|
||||
raise RuntimeError("LAB E3 CVAT review workspace is incompatible")
|
||||
artifacts: dict[str, Path] = {}
|
||||
descriptors = manifest.get("artifacts")
|
||||
if not isinstance(descriptors, list):
|
||||
raise RuntimeError("LAB E3 CVAT artifact list is invalid")
|
||||
for descriptor in descriptors:
|
||||
if not isinstance(descriptor, dict):
|
||||
raise RuntimeError("LAB E3 CVAT artifact descriptor is invalid")
|
||||
path = _safe_artifact(root, descriptor.get("path"))
|
||||
if (
|
||||
path.stat().st_size != descriptor.get("bytes")
|
||||
or not _valid_sha256(descriptor.get("sha256"))
|
||||
or _sha256(path) != descriptor["sha256"]
|
||||
):
|
||||
raise RuntimeError(f"LAB E3 CVAT artifact changed: {path.name}")
|
||||
artifacts[path.name] = path
|
||||
if set(artifacts) != {
|
||||
"labels.json",
|
||||
"control-fisheye-mask.zip",
|
||||
"challenger-kb4-cubemap5.zip",
|
||||
}:
|
||||
raise RuntimeError("LAB E3 CVAT artifact set changed")
|
||||
return manifest, artifacts
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--server", required=True)
|
||||
parser.add_argument("--username", required=True)
|
||||
parser.add_argument("--password-env", required=True)
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--images-zip", type=Path, required=True)
|
||||
parser.add_argument("--report", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
password = os.environ.get(args.password_env)
|
||||
if not password:
|
||||
raise RuntimeError(f"Password environment variable {args.password_env!r} is empty")
|
||||
workspace = args.workspace.resolve(strict=True)
|
||||
images_zip = args.images_zip.resolve(strict=True)
|
||||
manifest, artifacts = _workspace(workspace, images_zip)
|
||||
labels_document = _read_object(artifacts["labels.json"])
|
||||
semantic_labels = labels_document.get("semantic_task")
|
||||
if not isinstance(semantic_labels, list):
|
||||
raise RuntimeError("semantic labels are absent")
|
||||
labels = _label_specs(semantic_labels)
|
||||
task_specs = (
|
||||
{
|
||||
"name": "LAB E3 | K1 | EoMT fisheye control | pack 7a983bba",
|
||||
"annotation": artifacts["control-fisheye-mask.zip"],
|
||||
"role": "control",
|
||||
},
|
||||
{
|
||||
"name": "LAB E3 | K1 | EoMT KB4 cubemap5 challenger | pack 7a983bba",
|
||||
"annotation": artifacts["challenger-kb4-cubemap5.zip"],
|
||||
"role": "challenger",
|
||||
},
|
||||
)
|
||||
with make_client(args.server, credentials=(args.username, password)) as client:
|
||||
client.check_server_version(fail_if_unsupported=True)
|
||||
tasks = []
|
||||
for spec in task_specs:
|
||||
record = _ensure_task(
|
||||
client,
|
||||
name=spec["name"],
|
||||
labels=labels,
|
||||
images_path=images_zip,
|
||||
annotation_path=spec["annotation"],
|
||||
annotation_format="Segmentation mask 1.1",
|
||||
expected_shape_count=None,
|
||||
)
|
||||
record["role"] = spec["role"]
|
||||
tasks.append(record)
|
||||
|
||||
report = {
|
||||
"schema_version": "missioncore.lab-e3-cvat-import/v1",
|
||||
"created_at_utc": datetime.now(UTC)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z"),
|
||||
"server": args.server,
|
||||
"cvat_version": "v2.70.0",
|
||||
"workspace_id": manifest["workspace_id"],
|
||||
"workspace_manifest_sha256": _sha256(workspace / "manifest.json"),
|
||||
"evaluation_pack_id": EXPECTED_PACK_ID,
|
||||
"e3_result_id": EXPECTED_RESULT_ID,
|
||||
"ground_truth": False,
|
||||
"priority_image_ids": manifest["identity"]["priority_image_ids"],
|
||||
"tasks": tasks,
|
||||
"next_gate": "two-pass human review and reviewed export",
|
||||
}
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(report, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
readonly FREE_GIB_FLOOR="${FREE_GIB_FLOOR:-360}"
|
||||
readonly WINDOWS_ROOT="/mnt/d/NDC_MISSIONCORE"
|
||||
readonly COMPUTE_ROOT="${WINDOWS_ROOT}/workspace/mission-core-compute"
|
||||
readonly TOOL_ROOT="/srv/mission-core-cvat/tools/e2-import"
|
||||
readonly SECRET_FILE="${WINDOWS_ROOT}/secrets/cvat/admin.env"
|
||||
readonly WORKSPACE_ID="e3-cvat-review-ca599521e345446ca8e9af5a9013062099278e7317f83ff89740c6b092ddc52f"
|
||||
readonly WORKSPACE_ROOT="${WINDOWS_ROOT}/runtime/annotation/imports/LAB-E3/${WORKSPACE_ID}"
|
||||
readonly E2_WORKSPACE_ID="annotation-workspace-9a950d1c37d56dc12cc285b13c5addd7795285879cbcb1fbb2d5811c3c69821a"
|
||||
readonly IMAGES_ZIP="${WINDOWS_ROOT}/runtime/annotation/imports/LAB-E2/${E2_WORKSPACE_ID}/cvat/images.zip"
|
||||
readonly REPORT_ROOT="${WINDOWS_ROOT}/runtime/annotation/cvat/reports"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "import_e3_review.sh must run as root" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
free_gib() {
|
||||
df -BG --output=avail "${WINDOWS_ROOT}" | tail -n 1 | tr -dc '0-9'
|
||||
}
|
||||
|
||||
guard_disk() {
|
||||
local stage="$1"
|
||||
local free
|
||||
free="$(free_gib)"
|
||||
printf 'disk_guard stage=%s free_gib=%s floor_gib=%s\n' \
|
||||
"${stage}" "${free}" "${FREE_GIB_FLOOR}"
|
||||
if (( free < FREE_GIB_FLOOR )); then
|
||||
echo "D: free-space floor crossed; refusing further writes" >&2
|
||||
exit 3
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_cvat() {
|
||||
local attempt http_code
|
||||
for attempt in $(seq 1 60); do
|
||||
http_code="$(
|
||||
curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
"http://localhost:8080/api/server/about" || true
|
||||
)"
|
||||
if [[ "${http_code}" == "200" ]]; then
|
||||
printf 'cvat_ready attempt=%s\n' "${attempt}"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "CVAT API did not become ready within 120 seconds" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
test -x "${TOOL_ROOT}/venv/bin/python"
|
||||
test -f "${SECRET_FILE}"
|
||||
test -f "${WORKSPACE_ROOT}/manifest.json"
|
||||
test -f "${IMAGES_ZIP}"
|
||||
test -f "${COMPUTE_ROOT}/cvat/import_e2_workspace.py"
|
||||
test -f "${COMPUTE_ROOT}/cvat/import_e3_review.py"
|
||||
guard_disk preflight
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${SECRET_FILE}"
|
||||
set +a
|
||||
|
||||
wait_for_cvat
|
||||
report="${REPORT_ROOT}/lab-e3-cvat-import-$(date -u +%Y%m%dT%H%M%SZ).json"
|
||||
"${TOOL_ROOT}/venv/bin/python" "${COMPUTE_ROOT}/cvat/import_e3_review.py" \
|
||||
--server "http://localhost:8080" \
|
||||
--username "${CVAT_ADMIN_USERNAME}" \
|
||||
--password-env CVAT_ADMIN_PASSWORD \
|
||||
--workspace "${WORKSPACE_ROOT}" \
|
||||
--images-zip "${IMAGES_ZIP}" \
|
||||
--report "${report}"
|
||||
|
||||
guard_disk imported
|
||||
printf 'import_report=%s\n' "${report}"
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SSH_ALIAS="mission-gpu"
|
||||
readonly LOCAL_PORT="${CVAT_LOCAL_PORT:-18080}"
|
||||
readonly REMOTE_WSL_PORT="18080"
|
||||
readonly LINUX_START_SCRIPT='/mnt/d/NDC_MISSIONCORE/workspace/mission-core-compute/cvat/start_cvat_runtime.sh'
|
||||
|
||||
if curl --max-time 2 --fail --silent \
|
||||
"http://localhost:${LOCAL_PORT}/api/server/about" >/dev/null 2>&1; then
|
||||
printf 'cvat_tunnel_ready url=http://localhost:%s\n' "${LOCAL_PORT}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if command -v lsof >/dev/null \
|
||||
&& lsof -nP -iTCP:"${LOCAL_PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "Local port ${LOCAL_PORT} is already occupied by a non-responsive process" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
runtime_command="wsl.exe -d MissionCore-CVAT -u root -- bash ${LINUX_START_SCRIPT} --keepalive"
|
||||
background=false
|
||||
if [[ "${1:-}" == "--background" ]]; then
|
||||
background=true
|
||||
ssh -f "${SSH_ALIAS}" "${runtime_command}"
|
||||
else
|
||||
ssh "${SSH_ALIAS}" "${runtime_command}" &
|
||||
runtime_ssh_pid=$!
|
||||
cleanup() {
|
||||
if [[ -n "${tunnel_ssh_pid:-}" ]]; then
|
||||
kill "${tunnel_ssh_pid}" 2>/dev/null || true
|
||||
fi
|
||||
kill "${runtime_ssh_pid}" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
cvat_ip="$(
|
||||
ssh "${SSH_ALIAS}" \
|
||||
"wsl.exe -d MissionCore-CVAT -u root -- hostname -I" \
|
||||
| tr -d '\r' | awk '{print $1}'
|
||||
)"
|
||||
if [[ "${cvat_ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ ! "${cvat_ip:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "MissionCore-CVAT did not return a valid WSL address" >&2
|
||||
if [[ "${background}" == false ]]; then
|
||||
kill "${runtime_ssh_pid}" 2>/dev/null || true
|
||||
fi
|
||||
exit 3
|
||||
fi
|
||||
|
||||
forward=(
|
||||
-N
|
||||
-L "${LOCAL_PORT}:${cvat_ip}:${REMOTE_WSL_PORT}"
|
||||
-o ExitOnForwardFailure=yes
|
||||
"${SSH_ALIAS}"
|
||||
)
|
||||
|
||||
if [[ "${background}" == true ]]; then
|
||||
ssh -f "${forward[@]}"
|
||||
else
|
||||
ssh "${forward[@]}" &
|
||||
tunnel_ssh_pid=$!
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --max-time 2 --fail --silent \
|
||||
"http://localhost:${LOCAL_PORT}/api/server/about" >/dev/null 2>&1; then
|
||||
printf 'cvat_tunnel_ready url=http://localhost:%s wsl_ip=%s\n' \
|
||||
"${LOCAL_PORT}" "${cvat_ip}"
|
||||
if [[ "${background}" == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
wait "${tunnel_ssh_pid}"
|
||||
tunnel_status=$?
|
||||
kill "${runtime_ssh_pid}" 2>/dev/null || true
|
||||
exit "${tunnel_status}"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "CVAT tunnel did not become ready within 120 seconds" >&2
|
||||
if [[ "${background}" == false ]]; then
|
||||
kill "${tunnel_ssh_pid}" "${runtime_ssh_pid}" 2>/dev/null || true
|
||||
fi
|
||||
exit 4
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Do not inherit Windows executables into this D-only runtime. In particular,
|
||||
# Docker Desktop's docker.exe belongs to a different image store.
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
readonly CVAT_TAG="${CVAT_TAG:-v2.70.0}"
|
||||
readonly FREE_GIB_FLOOR="${FREE_GIB_FLOOR:-360}"
|
||||
readonly WINDOWS_ROOT="/mnt/d/NDC_MISSIONCORE"
|
||||
readonly COMPUTE_ROOT="${WINDOWS_ROOT}/workspace/mission-core-compute"
|
||||
readonly RUNTIME_ROOT="${WINDOWS_ROOT}/runtime/annotation/cvat"
|
||||
readonly CVAT_ROOT="${RUNTIME_ROOT}/source/cvat-${CVAT_TAG}"
|
||||
readonly STATE_ROOT="/srv/mission-core-cvat"
|
||||
readonly OVERRIDE_FILE="${COMPUTE_ROOT}/cvat/docker-compose.override.yml"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "provision_cvat_wsl.sh must run as root" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
free_gib() {
|
||||
df -BG --output=avail "${WINDOWS_ROOT}" | tail -n 1 | tr -dc '0-9'
|
||||
}
|
||||
|
||||
guard_disk() {
|
||||
local stage="$1"
|
||||
local free
|
||||
free="$(free_gib)"
|
||||
printf 'disk_guard stage=%s free_gib=%s floor_gib=%s\n' \
|
||||
"${stage}" "${free}" "${FREE_GIB_FLOOR}"
|
||||
if (( free < FREE_GIB_FLOOR )); then
|
||||
echo "D: free-space floor crossed; refusing further writes" >&2
|
||||
exit 3
|
||||
fi
|
||||
}
|
||||
|
||||
guard_disk preflight
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl git
|
||||
|
||||
if ! dpkg-query -W -f='${Status}' docker-ce 2>/dev/null \
|
||||
| grep -qx 'install ok installed'; then
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
arch="$(dpkg --print-architecture)"
|
||||
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu %s stable\n' \
|
||||
"${arch}" "${VERSION_CODENAME}" >/etc/apt/sources.list.d/docker.list
|
||||
apt-get update
|
||||
apt-get install -y \
|
||||
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
fi
|
||||
|
||||
systemctl enable --now docker
|
||||
docker version
|
||||
docker compose version
|
||||
guard_disk docker-engine
|
||||
|
||||
install -d -m 0750 \
|
||||
"${STATE_ROOT}/volumes/cvat_db" \
|
||||
"${STATE_ROOT}/volumes/cvat_data" \
|
||||
"${STATE_ROOT}/volumes/cvat_keys" \
|
||||
"${STATE_ROOT}/volumes/cvat_logs" \
|
||||
"${STATE_ROOT}/volumes/cvat_inmem_db" \
|
||||
"${STATE_ROOT}/volumes/cvat_events_db" \
|
||||
"${STATE_ROOT}/volumes/cvat_cache_db" \
|
||||
"${RUNTIME_ROOT}/source" \
|
||||
"${RUNTIME_ROOT}/reports"
|
||||
|
||||
if [[ ! -d "${CVAT_ROOT}/.git" ]]; then
|
||||
git clone --depth 1 --branch "${CVAT_TAG}" \
|
||||
https://github.com/cvat-ai/cvat.git "${CVAT_ROOT}"
|
||||
fi
|
||||
|
||||
test "$(git -C "${CVAT_ROOT}" describe --tags --exact-match)" = "${CVAT_TAG}"
|
||||
test -f "${OVERRIDE_FILE}"
|
||||
guard_disk cvat-source
|
||||
|
||||
export CVAT_VERSION="${CVAT_TAG}"
|
||||
export CVAT_HOST="localhost"
|
||||
export CVAT_HTTP_PORT="8080"
|
||||
export COMPOSE_PROJECT_NAME="missioncore-cvat"
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--project-directory "${CVAT_ROOT}"
|
||||
-f "${CVAT_ROOT}/docker-compose.yml"
|
||||
-f "${OVERRIDE_FILE}"
|
||||
)
|
||||
|
||||
"${compose[@]}" config --quiet
|
||||
mapfile -t images < <("${compose[@]}" config --images | sort -u)
|
||||
for image in "${images[@]}"; do
|
||||
guard_disk "before-pull:${image}"
|
||||
docker pull "${image}"
|
||||
guard_disk "after-pull:${image}"
|
||||
done
|
||||
|
||||
"${compose[@]}" up -d --no-build
|
||||
guard_disk cvat-started
|
||||
|
||||
deadline=$((SECONDS + 600))
|
||||
until curl --fail --silent --show-error http://localhost:8080/api/server/about \
|
||||
>/dev/null; do
|
||||
if (( SECONDS >= deadline )); then
|
||||
"${compose[@]}" ps
|
||||
echo "CVAT did not become ready within 600 seconds" >&2
|
||||
exit 4
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
report="${RUNTIME_ROOT}/reports/deployment-$(date -u +%Y%m%dT%H%M%SZ).txt"
|
||||
{
|
||||
printf 'cvat_tag=%s\n' "${CVAT_TAG}"
|
||||
printf 'cvat_commit=%s\n' "$(git -C "${CVAT_ROOT}" rev-parse HEAD)"
|
||||
printf 'docker_version=%s\n' "$(docker version --format '{{.Server.Version}}')"
|
||||
printf 'compose_version=%s\n' "$(docker compose version --short)"
|
||||
printf 'free_gib=%s\n' "$(free_gib)"
|
||||
printf 'generated_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
docker image inspect "${images[@]}" \
|
||||
--format 'image={{index .RepoTags 0}} id={{.Id}} size={{.Size}}'
|
||||
"${compose[@]}" ps
|
||||
} | tee "${report}"
|
||||
|
||||
printf 'CVAT ready at http://localhost:8080\nreport=%s\n' "${report}"
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
readonly FREE_GIB_FLOOR="${FREE_GIB_FLOOR:-360}"
|
||||
readonly WINDOWS_ROOT="/mnt/d/NDC_MISSIONCORE"
|
||||
readonly COMPUTE_ROOT="${WINDOWS_ROOT}/workspace/mission-core-compute"
|
||||
readonly CVAT_ROOT="${WINDOWS_ROOT}/runtime/annotation/cvat/source/cvat-v2.70.0"
|
||||
readonly OVERRIDE_FILE="${COMPUTE_ROOT}/cvat/docker-compose.override.yml"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "start_cvat_runtime.sh must run as root" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
free_gib="$(df -BG --output=avail "${WINDOWS_ROOT}" | tail -n 1 | tr -dc '0-9')"
|
||||
printf 'disk_guard stage=start free_gib=%s floor_gib=%s\n' \
|
||||
"${free_gib}" "${FREE_GIB_FLOOR}"
|
||||
if (( free_gib < FREE_GIB_FLOOR )); then
|
||||
echo "D: free-space floor crossed; refusing to start CVAT" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
test -f "${CVAT_ROOT}/docker-compose.yml"
|
||||
test -f "${OVERRIDE_FILE}"
|
||||
systemctl start docker
|
||||
|
||||
export CVAT_VERSION="v2.70.0"
|
||||
export CVAT_HOST="localhost"
|
||||
export CVAT_HTTP_PORT="8080"
|
||||
export COMPOSE_PROJECT_NAME="missioncore-cvat"
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--project-directory "${CVAT_ROOT}"
|
||||
-f "${CVAT_ROOT}/docker-compose.yml"
|
||||
-f "${OVERRIDE_FILE}"
|
||||
)
|
||||
"${compose[@]}" up -d --no-build
|
||||
|
||||
deadline=$((SECONDS + 180))
|
||||
while true; do
|
||||
about_http="$(
|
||||
curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
http://localhost:8080/api/server/about || true
|
||||
)"
|
||||
tasks_http="$(
|
||||
curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
http://localhost:8080/api/tasks || true
|
||||
)"
|
||||
if [[ "${about_http}" == "200" ]] \
|
||||
&& [[ "${tasks_http}" == "200" || "${tasks_http}" == "401" || "${tasks_http}" == "403" ]]; then
|
||||
break
|
||||
fi
|
||||
if (( SECONDS >= deadline )); then
|
||||
"${compose[@]}" ps
|
||||
echo "CVAT did not become ready within 180 seconds" >&2
|
||||
exit 4
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf 'cvat_ready about_http=%s tasks_http=%s free_gib=%s\n' \
|
||||
"${about_http}" "${tasks_http}" "${free_gib}"
|
||||
|
||||
if [[ "${1:-}" == "--keepalive" ]]; then
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"schema_version": "missioncore.e10-integrated-perception-profile/v1",
|
||||
"mode": "full-session-qualification",
|
||||
"source": {
|
||||
"source_id": "sensor.camera.right",
|
||||
"resolution": [800, 600],
|
||||
"calibration_slot": "camera_1",
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"selection": {
|
||||
"required_frame_count": 4489,
|
||||
"required_source_start_frame_index": 0,
|
||||
"required_source_end_frame_index": 4488,
|
||||
"minimum_source_span_seconds": 448.0
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"detector_queue_capacity": 2,
|
||||
"semantic_queue_capacity": 1,
|
||||
"semantic_sample_every_frames": 5,
|
||||
"semantic_ttl_ms": 750.0
|
||||
},
|
||||
"association": {
|
||||
"box_inset": {
|
||||
"bottom_fraction": 0.02,
|
||||
"horizontal_fraction": 0.05,
|
||||
"top_fraction": 0.04
|
||||
},
|
||||
"depth_cluster_gap_fraction": 0.06,
|
||||
"depth_cluster_minimum_gap_m": 0.5,
|
||||
"group_nms_iou_threshold": 0.55,
|
||||
"maximum_cuboid_span_m": 12.0,
|
||||
"maximum_distance_innovation_fraction": 0.25,
|
||||
"maximum_distance_innovation_m": 1.5,
|
||||
"maximum_oriented_extent_m": {
|
||||
"bicycle": [3.5, 2.0, 2.5],
|
||||
"motorcycle": [3.5, 2.0, 2.5],
|
||||
"person": [1.5, 1.5, 2.8],
|
||||
"vehicle": [6.5, 3.5, 4.0]
|
||||
},
|
||||
"minimum_cuboid_extent_m": 0.15,
|
||||
"minimum_support_points": {
|
||||
"bicycle": 3,
|
||||
"motorcycle": 3,
|
||||
"person": 3,
|
||||
"vehicle": 5
|
||||
},
|
||||
"semantic_ids": {
|
||||
"bicycle": [2],
|
||||
"motorcycle": [3],
|
||||
"person": [1],
|
||||
"vehicle": [4, 5]
|
||||
},
|
||||
"spatial_cluster_radius_m": {
|
||||
"bicycle": 0.9,
|
||||
"motorcycle": 0.9,
|
||||
"person": 0.9,
|
||||
"vehicle": 1.5
|
||||
},
|
||||
"vehicle_labels": ["car", "truck", "bus"],
|
||||
"distance_history_frames": 5
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"detector_minimum_effective_fps": 9.5,
|
||||
"detector_maximum_drop_fraction": 0.0,
|
||||
"maximum_p95_world_state_age_ms": 175.0,
|
||||
"semantic_minimum_effective_fps": 1.8,
|
||||
"semantic_maximum_drop_fraction": 0.05,
|
||||
"semantic_maximum_p95_completion_age_ms": 400.0,
|
||||
"minimum_fresh_semantic_coverage": 0.9,
|
||||
"minimum_lidar_fused_frames": 3500,
|
||||
"minimum_accepted_cuboids": 500,
|
||||
"require_zero_failures": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"schema_version": "missioncore.e10-integrated-perception-profile/v1",
|
||||
"mode": "qualification",
|
||||
"source": {
|
||||
"source_id": "sensor.camera.right",
|
||||
"resolution": [800, 600],
|
||||
"calibration_slot": "camera_1",
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"detector_queue_capacity": 2,
|
||||
"semantic_queue_capacity": 1,
|
||||
"semantic_sample_every_frames": 5,
|
||||
"semantic_ttl_ms": 750.0
|
||||
},
|
||||
"association": {
|
||||
"box_inset": {
|
||||
"bottom_fraction": 0.02,
|
||||
"horizontal_fraction": 0.05,
|
||||
"top_fraction": 0.04
|
||||
},
|
||||
"depth_cluster_gap_fraction": 0.06,
|
||||
"depth_cluster_minimum_gap_m": 0.5,
|
||||
"group_nms_iou_threshold": 0.55,
|
||||
"maximum_cuboid_span_m": 12.0,
|
||||
"maximum_distance_innovation_fraction": 0.25,
|
||||
"maximum_distance_innovation_m": 1.5,
|
||||
"maximum_oriented_extent_m": {
|
||||
"bicycle": [3.5, 2.0, 2.5],
|
||||
"motorcycle": [3.5, 2.0, 2.5],
|
||||
"person": [1.5, 1.5, 2.8],
|
||||
"vehicle": [6.5, 3.5, 4.0]
|
||||
},
|
||||
"minimum_cuboid_extent_m": 0.15,
|
||||
"minimum_support_points": {
|
||||
"bicycle": 3,
|
||||
"motorcycle": 3,
|
||||
"person": 3,
|
||||
"vehicle": 5
|
||||
},
|
||||
"semantic_ids": {
|
||||
"bicycle": [2],
|
||||
"motorcycle": [3],
|
||||
"person": [1],
|
||||
"vehicle": [4, 5]
|
||||
},
|
||||
"spatial_cluster_radius_m": {
|
||||
"bicycle": 0.9,
|
||||
"motorcycle": 0.9,
|
||||
"person": 0.9,
|
||||
"vehicle": 1.5
|
||||
},
|
||||
"vehicle_labels": ["car", "truck", "bus"],
|
||||
"distance_history_frames": 5
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"detector_minimum_effective_fps": 9.5,
|
||||
"detector_maximum_drop_fraction": 0.005,
|
||||
"maximum_p95_world_state_age_ms": 175.0,
|
||||
"semantic_minimum_effective_fps": 1.8,
|
||||
"semantic_maximum_drop_fraction": 0.05,
|
||||
"semantic_maximum_p95_completion_age_ms": 400.0,
|
||||
"minimum_fresh_semantic_coverage": 0.9,
|
||||
"minimum_lidar_fused_frames": 450,
|
||||
"minimum_accepted_cuboids": 100,
|
||||
"require_zero_failures": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"schema_version": "missioncore.e10-integrated-perception-profile/v1",
|
||||
"mode": "semantic-loss-negative-control",
|
||||
"source": {
|
||||
"source_id": "sensor.camera.right",
|
||||
"resolution": [800, 600],
|
||||
"calibration_slot": "camera_1",
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"detector_queue_capacity": 2,
|
||||
"semantic_queue_capacity": 1,
|
||||
"semantic_sample_every_frames": 5,
|
||||
"semantic_ttl_ms": 750.0
|
||||
},
|
||||
"semantic_loss": {
|
||||
"stop_after_completed_results": 20
|
||||
},
|
||||
"association": {
|
||||
"box_inset": {
|
||||
"bottom_fraction": 0.02,
|
||||
"horizontal_fraction": 0.05,
|
||||
"top_fraction": 0.04
|
||||
},
|
||||
"depth_cluster_gap_fraction": 0.06,
|
||||
"depth_cluster_minimum_gap_m": 0.5,
|
||||
"group_nms_iou_threshold": 0.55,
|
||||
"maximum_cuboid_span_m": 12.0,
|
||||
"maximum_distance_innovation_fraction": 0.25,
|
||||
"maximum_distance_innovation_m": 1.5,
|
||||
"maximum_oriented_extent_m": {
|
||||
"bicycle": [3.5, 2.0, 2.5],
|
||||
"motorcycle": [3.5, 2.0, 2.5],
|
||||
"person": [1.5, 1.5, 2.8],
|
||||
"vehicle": [6.5, 3.5, 4.0]
|
||||
},
|
||||
"minimum_cuboid_extent_m": 0.15,
|
||||
"minimum_support_points": {
|
||||
"bicycle": 3,
|
||||
"motorcycle": 3,
|
||||
"person": 3,
|
||||
"vehicle": 5
|
||||
},
|
||||
"semantic_ids": {
|
||||
"bicycle": [2],
|
||||
"motorcycle": [3],
|
||||
"person": [1],
|
||||
"vehicle": [4, 5]
|
||||
},
|
||||
"spatial_cluster_radius_m": {
|
||||
"bicycle": 0.9,
|
||||
"motorcycle": 0.9,
|
||||
"person": 0.9,
|
||||
"vehicle": 1.5
|
||||
},
|
||||
"vehicle_labels": ["car", "truck", "bus"],
|
||||
"distance_history_frames": 5
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"detector_minimum_effective_fps": 9.5,
|
||||
"detector_maximum_drop_fraction": 0.005,
|
||||
"maximum_p95_world_state_age_ms": 175.0,
|
||||
"minimum_stale_detector_frames": 450
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"schema_version": "missioncore.e10-integrated-perception-profile/v1",
|
||||
"mode": "pilot",
|
||||
"source": {
|
||||
"source_id": "sensor.camera.right",
|
||||
"resolution": [800, 600],
|
||||
"calibration_slot": "camera_1",
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"detector_queue_capacity": 2,
|
||||
"semantic_queue_capacity": 1,
|
||||
"semantic_sample_every_frames": 5,
|
||||
"semantic_ttl_ms": 750.0
|
||||
},
|
||||
"association": {
|
||||
"box_inset": {
|
||||
"bottom_fraction": 0.02,
|
||||
"horizontal_fraction": 0.05,
|
||||
"top_fraction": 0.04
|
||||
},
|
||||
"depth_cluster_gap_fraction": 0.06,
|
||||
"depth_cluster_minimum_gap_m": 0.5,
|
||||
"group_nms_iou_threshold": 0.5,
|
||||
"maximum_cuboid_span_m": 15.0,
|
||||
"maximum_distance_innovation_fraction": 0.25,
|
||||
"maximum_distance_innovation_m": 1.5,
|
||||
"maximum_oriented_extent_m": {
|
||||
"bicycle": [3.5, 2.0, 2.5],
|
||||
"motorcycle": [3.5, 2.0, 2.5],
|
||||
"person": [1.5, 1.5, 2.8],
|
||||
"vehicle": [12.5, 4.0, 4.5]
|
||||
},
|
||||
"minimum_cuboid_extent_m": 0.15,
|
||||
"minimum_support_points": {
|
||||
"bicycle": 4,
|
||||
"motorcycle": 4,
|
||||
"person": 4,
|
||||
"vehicle": 8
|
||||
},
|
||||
"semantic_ids": {
|
||||
"bicycle": [2],
|
||||
"motorcycle": [3],
|
||||
"person": [1],
|
||||
"vehicle": [4, 5]
|
||||
},
|
||||
"spatial_cluster_radius_m": {
|
||||
"bicycle": 0.9,
|
||||
"motorcycle": 0.9,
|
||||
"person": 0.9,
|
||||
"vehicle": 1.5
|
||||
},
|
||||
"vehicle_labels": ["car", "truck", "bus"],
|
||||
"distance_history_frames": 5
|
||||
},
|
||||
"cuboid_completion": {
|
||||
"mode": "class-prior-amodal-v1",
|
||||
"failure_policy": "reject",
|
||||
"classes": {
|
||||
"person": {
|
||||
"nominal_size_m": [0.55, 0.55, 1.72],
|
||||
"minimum_size_m": [0.35, 0.35, 1.3],
|
||||
"maximum_size_m": [1.2, 1.2, 2.3],
|
||||
"support_padding_m": [0.12, 0.12, 0.12]
|
||||
},
|
||||
"bicycle": {
|
||||
"nominal_size_m": [1.8, 0.65, 1.5],
|
||||
"minimum_size_m": [1.2, 0.4, 1.0],
|
||||
"maximum_size_m": [2.5, 1.2, 2.2],
|
||||
"support_padding_m": [0.18, 0.12, 0.12]
|
||||
},
|
||||
"motorcycle": {
|
||||
"nominal_size_m": [2.1, 0.8, 1.45],
|
||||
"minimum_size_m": [1.4, 0.5, 1.0],
|
||||
"maximum_size_m": [3.0, 1.4, 2.2],
|
||||
"support_padding_m": [0.2, 0.14, 0.14]
|
||||
},
|
||||
"car": {
|
||||
"nominal_size_m": [4.5, 1.85, 1.55],
|
||||
"minimum_size_m": [3.2, 1.45, 1.2],
|
||||
"maximum_size_m": [5.8, 2.4, 2.3],
|
||||
"support_padding_m": [0.25, 0.18, 0.15]
|
||||
},
|
||||
"truck": {
|
||||
"nominal_size_m": [7.0, 2.5, 3.0],
|
||||
"minimum_size_m": [4.8, 1.8, 1.8],
|
||||
"maximum_size_m": [12.5, 3.2, 4.2],
|
||||
"support_padding_m": [0.35, 0.22, 0.2]
|
||||
},
|
||||
"bus": {
|
||||
"nominal_size_m": [10.5, 2.55, 3.2],
|
||||
"minimum_size_m": [7.0, 2.1, 2.5],
|
||||
"maximum_size_m": [13.5, 3.2, 4.2],
|
||||
"support_padding_m": [0.4, 0.24, 0.2]
|
||||
}
|
||||
},
|
||||
"ground": {
|
||||
"local_radius_m": 2.5,
|
||||
"lower_percentile": 8.0,
|
||||
"maximum_below_support_m": 1.2,
|
||||
"maximum_above_support_m": 0.15,
|
||||
"fallback_below_support_m": 0.25
|
||||
},
|
||||
"orientation": {
|
||||
"minimum_anisotropy_ratio": 1.35,
|
||||
"face_width_switch_fraction": 1.05
|
||||
},
|
||||
"temporal": {
|
||||
"center_alpha": 0.4,
|
||||
"size_alpha": 0.2,
|
||||
"yaw_alpha": 0.25,
|
||||
"maximum_center_innovation_m": 2.0,
|
||||
"maximum_yaw_innovation_degrees": 55.0,
|
||||
"maximum_idle_s": 1.0,
|
||||
"confirmation_hits": 3
|
||||
},
|
||||
"minimum_support_coverage_fraction": 0.75
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"detector_minimum_effective_fps": 9.5,
|
||||
"detector_maximum_drop_fraction": 0.005,
|
||||
"maximum_p95_world_state_age_ms": 175.0,
|
||||
"semantic_minimum_effective_fps": 1.8,
|
||||
"semantic_maximum_drop_fraction": 0.05,
|
||||
"semantic_maximum_p95_completion_age_ms": 400.0,
|
||||
"minimum_fresh_semantic_coverage": 0.9,
|
||||
"minimum_lidar_fused_frames": 450,
|
||||
"minimum_accepted_cuboids": 100,
|
||||
"require_zero_failures": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
{
|
||||
"schema_version": "missioncore.e10-integrated-perception-profile/v1",
|
||||
"mode": "full-session-qualification",
|
||||
"source": {
|
||||
"source_id": "sensor.camera.right",
|
||||
"resolution": [800, 600],
|
||||
"calibration_slot": "camera_1",
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"selection": {
|
||||
"required_frame_count": 4489,
|
||||
"required_source_start_frame_index": 0,
|
||||
"required_source_end_frame_index": 4488,
|
||||
"minimum_source_span_seconds": 448.0
|
||||
},
|
||||
"replay": {
|
||||
"speed": 1.0,
|
||||
"detector_queue_capacity": 2,
|
||||
"semantic_queue_capacity": 1,
|
||||
"semantic_sample_every_frames": 5,
|
||||
"semantic_ttl_ms": 750.0
|
||||
},
|
||||
"association": {
|
||||
"box_inset": {
|
||||
"bottom_fraction": 0.02,
|
||||
"horizontal_fraction": 0.05,
|
||||
"top_fraction": 0.04
|
||||
},
|
||||
"depth_cluster_gap_fraction": 0.06,
|
||||
"depth_cluster_minimum_gap_m": 0.5,
|
||||
"group_nms_iou_threshold": 0.5,
|
||||
"maximum_cuboid_span_m": 15.0,
|
||||
"maximum_distance_innovation_fraction": 0.25,
|
||||
"maximum_distance_innovation_m": 1.5,
|
||||
"maximum_oriented_extent_m": {
|
||||
"bicycle": [3.5, 2.0, 2.5],
|
||||
"motorcycle": [3.5, 2.0, 2.5],
|
||||
"person": [1.5, 1.5, 2.8],
|
||||
"vehicle": [12.5, 4.0, 4.5]
|
||||
},
|
||||
"minimum_cuboid_extent_m": 0.15,
|
||||
"minimum_support_points": {
|
||||
"bicycle": 4,
|
||||
"motorcycle": 4,
|
||||
"person": 4,
|
||||
"vehicle": 8
|
||||
},
|
||||
"semantic_ids": {
|
||||
"bicycle": [2],
|
||||
"motorcycle": [3],
|
||||
"person": [1],
|
||||
"vehicle": [4, 5]
|
||||
},
|
||||
"spatial_cluster_radius_m": {
|
||||
"bicycle": 0.9,
|
||||
"motorcycle": 0.9,
|
||||
"person": 0.9,
|
||||
"vehicle": 1.5
|
||||
},
|
||||
"vehicle_labels": ["car", "truck", "bus"],
|
||||
"distance_history_frames": 5
|
||||
},
|
||||
"cuboid_completion": {
|
||||
"mode": "class-prior-amodal-v1",
|
||||
"failure_policy": "reject",
|
||||
"classes": {
|
||||
"person": {
|
||||
"nominal_size_m": [0.55, 0.55, 1.72],
|
||||
"minimum_size_m": [0.35, 0.35, 1.3],
|
||||
"maximum_size_m": [1.2, 1.2, 2.3],
|
||||
"support_padding_m": [0.12, 0.12, 0.12]
|
||||
},
|
||||
"bicycle": {
|
||||
"nominal_size_m": [1.8, 0.65, 1.5],
|
||||
"minimum_size_m": [1.2, 0.4, 1.0],
|
||||
"maximum_size_m": [2.5, 1.2, 2.2],
|
||||
"support_padding_m": [0.18, 0.12, 0.12]
|
||||
},
|
||||
"motorcycle": {
|
||||
"nominal_size_m": [2.1, 0.8, 1.45],
|
||||
"minimum_size_m": [1.4, 0.5, 1.0],
|
||||
"maximum_size_m": [3.0, 1.4, 2.2],
|
||||
"support_padding_m": [0.2, 0.14, 0.14]
|
||||
},
|
||||
"car": {
|
||||
"nominal_size_m": [4.5, 1.85, 1.55],
|
||||
"minimum_size_m": [3.2, 1.45, 1.2],
|
||||
"maximum_size_m": [5.8, 2.4, 2.3],
|
||||
"support_padding_m": [0.25, 0.18, 0.15]
|
||||
},
|
||||
"truck": {
|
||||
"nominal_size_m": [7.0, 2.5, 3.0],
|
||||
"minimum_size_m": [4.8, 1.8, 1.8],
|
||||
"maximum_size_m": [12.5, 3.2, 4.2],
|
||||
"support_padding_m": [0.35, 0.22, 0.2]
|
||||
},
|
||||
"bus": {
|
||||
"nominal_size_m": [10.5, 2.55, 3.2],
|
||||
"minimum_size_m": [7.0, 2.1, 2.5],
|
||||
"maximum_size_m": [13.5, 3.2, 4.2],
|
||||
"support_padding_m": [0.4, 0.24, 0.2]
|
||||
}
|
||||
},
|
||||
"ground": {
|
||||
"local_radius_m": 2.5,
|
||||
"lower_percentile": 8.0,
|
||||
"maximum_below_support_m": 1.2,
|
||||
"maximum_above_support_m": 0.15,
|
||||
"fallback_below_support_m": 0.25
|
||||
},
|
||||
"orientation": {
|
||||
"minimum_anisotropy_ratio": 1.35,
|
||||
"face_width_switch_fraction": 1.05
|
||||
},
|
||||
"temporal": {
|
||||
"center_alpha": 0.4,
|
||||
"size_alpha": 0.2,
|
||||
"yaw_alpha": 0.25,
|
||||
"maximum_center_innovation_m": 2.0,
|
||||
"maximum_yaw_innovation_degrees": 55.0,
|
||||
"maximum_idle_s": 1.0,
|
||||
"confirmation_hits": 3
|
||||
},
|
||||
"minimum_support_coverage_fraction": 0.75
|
||||
},
|
||||
"world_state": {
|
||||
"velocity_history_limit_s": 1.0,
|
||||
"clearance": {
|
||||
"sector_count": 72,
|
||||
"minimum_range_m": 0.5,
|
||||
"maximum_range_m": 30.0,
|
||||
"ground_percentile": 5.0,
|
||||
"minimum_height_above_ground_m": 0.2,
|
||||
"maximum_height_above_ground_m": 3.0,
|
||||
"front_half_angle_degrees": 15.0
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"detector_minimum_effective_fps": 9.5,
|
||||
"detector_maximum_drop_fraction": 0.005,
|
||||
"maximum_p95_world_state_age_ms": 175.0,
|
||||
"semantic_minimum_effective_fps": 1.8,
|
||||
"semantic_maximum_drop_fraction": 0.05,
|
||||
"semantic_maximum_p95_completion_age_ms": 400.0,
|
||||
"minimum_fresh_semantic_coverage": 0.9,
|
||||
"minimum_lidar_fused_frames": 3500,
|
||||
"minimum_accepted_cuboids": 1500,
|
||||
"require_zero_failures": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"schema_version": "missioncore.e15-shadow-inference-profile/v1",
|
||||
"mode": "replay-shadow-gate",
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
},
|
||||
"source": {
|
||||
"source_id": "sensor.camera.right",
|
||||
"resolution": [800, 600],
|
||||
"calibration_slot": "camera_1",
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"transport": {
|
||||
"wire_schema": "missioncore.live-perception-wire/v1",
|
||||
"camera_media": "persistent-fmp4-pyav",
|
||||
"pyav_version": "18.0.0",
|
||||
"maximum_media_buffer_bytes": 8388608,
|
||||
"camera_metadata_capacity": 16
|
||||
},
|
||||
"scheduling": {
|
||||
"detector_queue_capacity": 2,
|
||||
"semantic_queue_capacity": 1,
|
||||
"semantic_sample_every_frames": 5,
|
||||
"semantic_ttl_ms": 750.0,
|
||||
"sensor_wait_ms": 90.0
|
||||
},
|
||||
"temporal": {
|
||||
"binding": "nearest-recorded-host-arrival-best-effort",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0,
|
||||
"buffer_capacity_per_modality": 32,
|
||||
"retention_seconds": 3.0,
|
||||
"clock_qualification": "not-hardware-synchronized"
|
||||
},
|
||||
"acceptance": {
|
||||
"minimum_camera_frames": 140,
|
||||
"detector_minimum_effective_fps": 9.5,
|
||||
"detector_maximum_drop_fraction": 0.01,
|
||||
"semantic_minimum_effective_fps": 1.8,
|
||||
"semantic_maximum_drop_fraction": 0.05,
|
||||
"semantic_maximum_p95_completion_age_ms": 400.0,
|
||||
"minimum_fresh_semantic_coverage": 0.9,
|
||||
"minimum_fused_fraction": 0.85,
|
||||
"maximum_p95_decode_age_ms": 80.0,
|
||||
"maximum_p95_world_state_age_ms": 200.0,
|
||||
"require_zero_transport_gaps": true,
|
||||
"require_zero_camera_sequence_gaps": true,
|
||||
"require_zero_failures": true
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue