Compare commits
3
Commits
2920fc7579
...
d943ec853c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d943ec853c | ||
|
|
b111406cf8 | ||
|
|
28effdde23 |
Generated
+20
-1
@@ -16,6 +16,7 @@
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "0.185.1"
|
||||
@@ -1375,7 +1376,6 @@
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
@@ -1399,6 +1399,12 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.72",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
|
||||
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.43",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
|
||||
@@ -1714,6 +1720,19 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playcanvas": {
|
||||
"version": "2.21.4",
|
||||
"resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-2.21.4.tgz",
|
||||
"integrity": "sha512-L4UGy3z/YT8AaNBEY4ITsZup548UFMabF7loh0YQ0DRwQLdjIW8grmZPoQ1+B83+N6sejRS0/XlXV2Vfb11+fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/webxr": "^0.5.24",
|
||||
"@webgpu/types": "^0.1.70"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"three": "0.185.1"
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Application,
|
||||
Asset,
|
||||
Color,
|
||||
Entity,
|
||||
Vec2,
|
||||
Vec3,
|
||||
type ScriptType,
|
||||
} from "playcanvas";
|
||||
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
||||
|
||||
import type { SimulationWorldManifest } from "../../core/simulation/projects";
|
||||
|
||||
export type SimulationViewMode = "visual" | "collision" | "combined";
|
||||
export type SimulationQuality = "auto" | "low" | "medium" | "high";
|
||||
|
||||
export interface SimulationRuntime {
|
||||
mount(canvas: HTMLCanvasElement): Promise<void>;
|
||||
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
||||
setViewMode(mode: SimulationViewMode): void;
|
||||
setQuality(quality: SimulationQuality): void;
|
||||
focusBounds(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus">;
|
||||
|
||||
export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
private app: Application | null = null;
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private camera: Entity | null = null;
|
||||
private cameraController: CameraController | null = null;
|
||||
private visualEntity: Entity | null = null;
|
||||
private visualAsset: Asset | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private viewMode: SimulationViewMode = "visual";
|
||||
private quality: SimulationQuality = "auto";
|
||||
|
||||
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
||||
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
||||
this.canvas = canvas;
|
||||
canvas.tabIndex = 0;
|
||||
canvas.addEventListener("contextmenu", preventContextMenu);
|
||||
const app = new Application(canvas, {
|
||||
graphicsDeviceOptions: {
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
preserveDrawingBuffer: false,
|
||||
powerPreference: "high-performance",
|
||||
},
|
||||
});
|
||||
this.app = app;
|
||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||
|
||||
const camera = new Entity("SimulationCamera");
|
||||
camera.addComponent("camera", {
|
||||
clearColor: new Color(0.025, 0.03, 0.04),
|
||||
nearClip: 0.02,
|
||||
farClip: 20_000,
|
||||
fov: 58,
|
||||
});
|
||||
camera.setPosition(5, 3, 5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.addComponent("script");
|
||||
const controller = camera.script?.create(CameraControls, {
|
||||
properties: {
|
||||
enableOrbit: true,
|
||||
enableFly: true,
|
||||
focusPoint: new Vec3(0, 0, 0),
|
||||
zoomRange: new Vec2(0.05, 20_000),
|
||||
},
|
||||
}) as CameraController | null;
|
||||
app.root.addChild(camera);
|
||||
this.camera = camera;
|
||||
this.cameraController = controller;
|
||||
|
||||
const light = new Entity("SimulationSun");
|
||||
light.addComponent("light", {
|
||||
type: "directional",
|
||||
color: new Color(1, 0.97, 0.9),
|
||||
intensity: 1.1,
|
||||
castShadows: false,
|
||||
});
|
||||
light.setEulerAngles(42, 28, 0);
|
||||
app.root.addChild(light);
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.resizeObserver.observe(canvas.parentElement ?? canvas);
|
||||
this.resize();
|
||||
app.start();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async loadWorld(manifest: SimulationWorldManifest): Promise<void> {
|
||||
const app = this.requiredApp();
|
||||
this.unloadWorld();
|
||||
const sourceUrl = manifest.visual.streamedSogUrl ?? manifest.visual.previewSogUrl;
|
||||
if (!sourceUrl) throw new Error("World manifest не содержит визуальный SOG.");
|
||||
const asset = new Asset(`SimulationWorld:${manifest.projectId}`, "gsplat", { url: sourceUrl });
|
||||
app.assets.add(asset);
|
||||
this.visualAsset = asset;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ready = (loaded: Asset) => {
|
||||
const visual = new Entity("GaussianWorld");
|
||||
visual.enabled = false;
|
||||
app.root.addChild(visual);
|
||||
visual.addComponent("gsplat", {
|
||||
asset: loaded,
|
||||
unified: true,
|
||||
});
|
||||
visual.enabled = true;
|
||||
this.visualEntity = visual;
|
||||
this.applyQuality();
|
||||
this.applyViewMode();
|
||||
this.focusBounds();
|
||||
resolve();
|
||||
};
|
||||
const failed = (error: unknown) => {
|
||||
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил SOG."));
|
||||
};
|
||||
asset.ready(ready);
|
||||
asset.once("error", failed);
|
||||
app.assets.load(asset);
|
||||
});
|
||||
}
|
||||
|
||||
setViewMode(mode: SimulationViewMode): void {
|
||||
this.viewMode = mode;
|
||||
this.applyViewMode();
|
||||
}
|
||||
|
||||
setQuality(quality: SimulationQuality): void {
|
||||
this.quality = quality;
|
||||
this.applyQuality();
|
||||
}
|
||||
|
||||
focusBounds(): void {
|
||||
const bounds = this.visualEntity?.gsplat?.customAabb;
|
||||
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
||||
const radius = bounds?.halfExtents.length() ?? 3;
|
||||
const distance = Math.max(1, radius * 2.4);
|
||||
const position = new Vec3(1, 0.55, 1).normalize().mulScalar(distance).add(focus);
|
||||
if (this.cameraController) {
|
||||
this.cameraController.reset(focus, position);
|
||||
} else if (this.camera) {
|
||||
this.camera.setPosition(position);
|
||||
this.camera.lookAt(focus);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = null;
|
||||
this.unloadWorld();
|
||||
if (this.app) this.app.destroy();
|
||||
if (this.canvas) this.canvas.removeEventListener("contextmenu", preventContextMenu);
|
||||
this.app = null;
|
||||
this.canvas = null;
|
||||
this.camera = null;
|
||||
this.cameraController = null;
|
||||
}
|
||||
|
||||
private requiredApp(): Application {
|
||||
if (!this.app) throw new Error("PlayCanvas runtime не смонтирован.");
|
||||
return this.app;
|
||||
}
|
||||
|
||||
private resize(): void {
|
||||
if (!this.app || !this.canvas) return;
|
||||
const bounds = this.canvas.parentElement?.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.round(bounds?.width ?? this.canvas.clientWidth));
|
||||
const height = Math.max(1, Math.round(bounds?.height ?? this.canvas.clientHeight));
|
||||
this.app.resizeCanvas(width, height);
|
||||
}
|
||||
|
||||
private applyViewMode(): void {
|
||||
if (this.visualEntity) {
|
||||
this.visualEntity.enabled = this.viewMode !== "collision";
|
||||
}
|
||||
}
|
||||
|
||||
private applyQuality(): void {
|
||||
const gsplat = this.visualEntity?.gsplat;
|
||||
if (!gsplat) return;
|
||||
const profiles: Record<SimulationQuality, [number, number]> = {
|
||||
auto: [5, 2],
|
||||
low: [2.5, 1.7],
|
||||
medium: [5, 2],
|
||||
high: [9, 2.3],
|
||||
};
|
||||
const [baseDistance, multiplier] = profiles[this.quality];
|
||||
gsplat.lodBaseDistance = baseDistance;
|
||||
gsplat.lodMultiplier = multiplier;
|
||||
}
|
||||
|
||||
private unloadWorld(): void {
|
||||
if (this.visualEntity) {
|
||||
this.visualEntity.destroy();
|
||||
this.visualEntity = null;
|
||||
}
|
||||
if (this.visualAsset && this.app) {
|
||||
this.app.assets.remove(this.visualAsset);
|
||||
this.visualAsset.unload();
|
||||
this.visualAsset = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preventContextMenu(event: Event): void {
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
FieldFrame,
|
||||
Icon,
|
||||
Select,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createAndUploadSimulationProject,
|
||||
updateSimulationProject,
|
||||
type SimulationProject,
|
||||
type SimulationSceneType,
|
||||
type SimulationUploadCandidate,
|
||||
type SimulationUploadProgress,
|
||||
} from "../../core/simulation/projects";
|
||||
|
||||
export function SimulationProjectWindow({
|
||||
open,
|
||||
project,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
project: SimulationProject | null;
|
||||
onClose: () => void;
|
||||
onSaved: (project: SimulationProject) => void;
|
||||
}) {
|
||||
const archiveInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [sceneType, setSceneType] = useState<SimulationSceneType>("outdoor");
|
||||
const [candidates, setCandidates] = useState<SimulationUploadCandidate[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [progress, setProgress] = useState<SimulationUploadProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(project?.name ?? "");
|
||||
setSceneType(project?.sceneType ?? "outdoor");
|
||||
setCandidates([]);
|
||||
setProgress(null);
|
||||
setPending(false);
|
||||
setError(null);
|
||||
}, [open, project]);
|
||||
|
||||
const sourceSummary = useMemo(() => summarizeCandidates(candidates), [candidates]);
|
||||
const canSubmit = name.trim().length > 0 && (project !== null || candidates.length > 0) && !pending;
|
||||
|
||||
const acceptCandidates = (next: SimulationUploadCandidate[]) => {
|
||||
try {
|
||||
validateCandidates(next);
|
||||
setCandidates(next);
|
||||
setError(null);
|
||||
if (!name.trim() && next.length) {
|
||||
const named = next.find((candidate) => /\.lcc2?$/i.test(candidate.logicalPath)) ?? next[0];
|
||||
const first = named?.logicalPath.split("/").filter(Boolean).at(-1) ?? "Новая сцена";
|
||||
setName(first.replace(/\.(lcc2?|zip|rar|7z)$/i, ""));
|
||||
}
|
||||
} catch (caught) {
|
||||
setCandidates([]);
|
||||
setError(caught instanceof Error ? caught.message : "Источник не принят.");
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = project
|
||||
? await updateSimulationProject(project.projectId, name, sceneType)
|
||||
: await createAndUploadSimulationProject(
|
||||
name,
|
||||
sceneType,
|
||||
candidates,
|
||||
setProgress,
|
||||
);
|
||||
onSaved(saved);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось сохранить проект.");
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
try {
|
||||
acceptCandidates(await candidatesFromDrop(event.dataTransfer));
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось прочитать источник.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window
|
||||
open={open}
|
||||
title={project ? "Редактировать проект" : "Новый проект симуляции"}
|
||||
subtitle={project ? "Метаданные сцены" : "LCC/LCC2 folder или ZIP/RAR/7z"}
|
||||
size="md"
|
||||
closeOnBackdrop={!pending}
|
||||
closeOnEscape={!pending}
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<span className="simulation-project-window__footer-state">
|
||||
{pending ? <><ActivityIndicator /><span>{project ? "Сохраняем" : "Загружаем источник"}</span></> : null}
|
||||
</span>
|
||||
<WindowFooterActions>
|
||||
<Button disabled={pending} onClick={onClose}>Отмена</Button>
|
||||
<Button variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
|
||||
{project ? "Сохранить" : "Создать и запустить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="simulation-project-window">
|
||||
<TextField
|
||||
label="Название"
|
||||
value={name}
|
||||
maxLength={120}
|
||||
disabled={pending}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
/>
|
||||
<FieldFrame label="Тип сцены" description="Сохраняется как профиль будущей физики и навигации.">
|
||||
<Select
|
||||
label="Тип сцены"
|
||||
value={sceneType}
|
||||
disabled={pending}
|
||||
onChange={(value) => setSceneType(value)}
|
||||
options={[
|
||||
{ value: "outdoor", label: "Улица" },
|
||||
{ value: "interior", label: "Интерьер" },
|
||||
{ value: "object", label: "Отдельный объект" },
|
||||
]}
|
||||
/>
|
||||
</FieldFrame>
|
||||
{!project ? (
|
||||
<FieldFrame
|
||||
label="Исходник"
|
||||
description="Имя родительской папки не фиксировано. Внутри должна быть ровно одна сцена LCC или LCC2."
|
||||
>
|
||||
<div
|
||||
className="simulation-source-drop"
|
||||
data-dragging={dragging ? "true" : undefined}
|
||||
onDragEnter={(event) => { event.preventDefault(); setDragging(true); }}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
|
||||
}}
|
||||
onDrop={(event) => void handleDrop(event)}
|
||||
>
|
||||
<Icon name={candidates.length ? "check" : "upload"} size={20} />
|
||||
<strong>{sourceSummary.title}</strong>
|
||||
<span>{sourceSummary.detail}</span>
|
||||
<div>
|
||||
<Button
|
||||
size="compact"
|
||||
icon={<Icon name="file" size={16} />}
|
||||
disabled={pending}
|
||||
onClick={() => archiveInput.current?.click()}
|
||||
>
|
||||
Выбрать архив
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
icon={<Icon name="folder" size={16} />}
|
||||
disabled={pending}
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
Выбрать папку
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={archiveInput}
|
||||
type="file"
|
||||
accept=".zip,.rar,.7z"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (file) acceptCandidates([{ file, logicalPath: file.name }]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInput}
|
||||
type="file"
|
||||
hidden
|
||||
multiple
|
||||
{...({ webkitdirectory: "" } as Record<string, string>)}
|
||||
onChange={(event) => {
|
||||
const next = Array.from(event.currentTarget.files ?? []).map((file) => ({
|
||||
file,
|
||||
logicalPath: file.webkitRelativePath || file.name,
|
||||
}));
|
||||
if (next.length) acceptCandidates(next);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FieldFrame>
|
||||
) : null}
|
||||
{progress ? (
|
||||
<div className="simulation-upload-progress" role="status">
|
||||
<span><i style={{ width: `${Math.min(100, progress.totalBytes ? progress.uploadedBytes / progress.totalBytes * 100 : 0)}%` }} /></span>
|
||||
<div>
|
||||
<strong>{formatBytes(progress.uploadedBytes)} / {formatBytes(progress.totalBytes)}</strong>
|
||||
<small>{progress.currentPath}</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="simulation-project-window__error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
interface LegacyFileEntry {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file?: (callback: (file: File) => void, error?: (error: DOMException) => void) => void;
|
||||
createReader?: () => {
|
||||
readEntries: (
|
||||
callback: (entries: LegacyFileEntry[]) => void,
|
||||
error?: (error: DOMException) => void,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
|
||||
async function candidatesFromDrop(transfer: DataTransfer): Promise<SimulationUploadCandidate[]> {
|
||||
const candidates: SimulationUploadCandidate[] = [];
|
||||
for (const item of Array.from(transfer.items)) {
|
||||
const entry = (item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => LegacyFileEntry | null;
|
||||
}).webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
await collectEntry(entry, candidates);
|
||||
continue;
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (file) candidates.push({ file, logicalPath: file.name });
|
||||
}
|
||||
if (!candidates.length) {
|
||||
for (const file of Array.from(transfer.files)) {
|
||||
candidates.push({ file, logicalPath: file.name });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function collectEntry(entry: LegacyFileEntry, target: SimulationUploadCandidate[]): Promise<void> {
|
||||
if (entry.isFile && entry.file) {
|
||||
const file = await new Promise<File>((resolve, reject) => entry.file?.(resolve, reject));
|
||||
target.push({ file, logicalPath: entry.fullPath.replace(/^\//, "") || file.name });
|
||||
return;
|
||||
}
|
||||
if (!entry.isDirectory || !entry.createReader) return;
|
||||
const reader = entry.createReader();
|
||||
while (true) {
|
||||
const batch = await new Promise<LegacyFileEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
if (!batch.length) break;
|
||||
for (const child of batch) await collectEntry(child, target);
|
||||
}
|
||||
}
|
||||
|
||||
function validateCandidates(candidates: SimulationUploadCandidate[]): void {
|
||||
if (!candidates.length) throw new Error("Выберите архив или папку с результатом LCC/LCC2.");
|
||||
const archives = candidates.filter((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath));
|
||||
if (archives.length) {
|
||||
if (candidates.length !== 1) throw new Error("Архив нужно загружать одним файлом.");
|
||||
return;
|
||||
}
|
||||
const descriptors = candidates.filter((candidate) => /\.lcc2?$/i.test(candidate.logicalPath));
|
||||
if (descriptors.length !== 1) {
|
||||
throw new Error("В папке должна быть ровно одна сцена .lcc или .lcc2.");
|
||||
}
|
||||
const paths = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const parts = candidate.logicalPath.split("/");
|
||||
if (
|
||||
!candidate.logicalPath ||
|
||||
candidate.logicalPath.startsWith("/") ||
|
||||
candidate.logicalPath.includes("\\") ||
|
||||
candidate.logicalPath.includes("\0") ||
|
||||
parts.some((part) => part === "" || part === "." || part === "..")
|
||||
) {
|
||||
throw new Error("Папка содержит небезопасный путь.");
|
||||
}
|
||||
if (candidate.file.size <= 0) throw new Error(`Пустой файл: ${candidate.logicalPath}`);
|
||||
if (paths.has(candidate.logicalPath)) throw new Error(`Повторяющийся путь: ${candidate.logicalPath}`);
|
||||
paths.add(candidate.logicalPath);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeCandidates(candidates: SimulationUploadCandidate[]): { title: string; detail: string } {
|
||||
if (!candidates.length) return {
|
||||
title: "Перетащите архив или папку сюда",
|
||||
detail: "ZIP, RAR, 7z либо parent folder с LCC/LCC2",
|
||||
};
|
||||
const total = candidates.reduce((sum, candidate) => sum + candidate.file.size, 0);
|
||||
const archive = candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "");
|
||||
return {
|
||||
title: archive ? candidates[0]?.logicalPath ?? "Архив" : "Папка принята",
|
||||
detail: `${candidates.length.toLocaleString("ru-RU")} файл(ов) · ${formatBytes(total)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`;
|
||||
return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Button, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { SimulationProject } from "../../core/simulation/projects";
|
||||
import {
|
||||
PlayCanvasRuntime,
|
||||
type SimulationQuality,
|
||||
type SimulationViewMode,
|
||||
} from "./PlayCanvasRuntime";
|
||||
|
||||
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
||||
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
|
||||
const [quality, setQuality] = useState<SimulationQuality>("auto");
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const manifest = project.worldManifest;
|
||||
if (!canvas || !manifest) return;
|
||||
const runtime = new PlayCanvasRuntime();
|
||||
runtimeRef.current = runtime;
|
||||
let cancelled = false;
|
||||
setState("mounting");
|
||||
setError(null);
|
||||
void runtime.mount(canvas).then(async () => {
|
||||
if (cancelled) return;
|
||||
setState("loading");
|
||||
await runtime.loadWorld(manifest);
|
||||
if (!cancelled) setState("ready");
|
||||
}).catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState("failed");
|
||||
setError(caught instanceof Error ? caught.message : "Не удалось открыть сцену.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
runtime.dispose();
|
||||
runtimeRef.current = null;
|
||||
};
|
||||
}, [project.projectId, project.worldManifest]);
|
||||
|
||||
const collisionAvailable = project.worldManifest?.collision.available ?? false;
|
||||
|
||||
return (
|
||||
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
||||
<header className="simulation-viewport__toolbar">
|
||||
<div>
|
||||
<StatusBadge tone={state === "ready" ? "success" : state === "failed" ? "warning" : "accent"}>
|
||||
{state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"}
|
||||
</StatusBadge>
|
||||
<span>PlayCanvas Engine 2.21.4</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
label="Слой сцены"
|
||||
value={viewMode}
|
||||
onChange={(next) => {
|
||||
setViewMode(next);
|
||||
runtimeRef.current?.setViewMode(next);
|
||||
}}
|
||||
items={[
|
||||
{ value: "visual", label: "Визуал" },
|
||||
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
|
||||
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Качество Streamed SOG"
|
||||
value={quality}
|
||||
onChange={(next) => {
|
||||
setQuality(next);
|
||||
runtimeRef.current?.setQuality(next);
|
||||
}}
|
||||
items={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Med" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
/>
|
||||
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.focusBounds()}>
|
||||
Вписать сцену
|
||||
</Button>
|
||||
</header>
|
||||
<div className="simulation-viewport__stage">
|
||||
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
||||
{state !== "ready" ? (
|
||||
<div className="simulation-viewport__state" role={state === "failed" ? "alert" : "status"}>
|
||||
{state === "failed" ? null : <ActivityIndicator label="Загрузка сцены" />}
|
||||
<strong>{state === "failed" ? "Сцена не открылась" : "PlayCanvas загружает мир"}</strong>
|
||||
<p>{error ?? "Streamed SOG догружается по мере готовности runtime."}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!collisionAvailable ? (
|
||||
<footer className="simulation-viewport__notice">
|
||||
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
||||
<span>Worker 006 сейчас работает в CPU-режиме; визуальный слой настоящий, collision GLB не подменяется.</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
export type SimulationSceneType = "interior" | "outdoor" | "object";
|
||||
export type SimulationProjectStatus =
|
||||
| "uploading"
|
||||
| "queued"
|
||||
| "processing"
|
||||
| "importing"
|
||||
| "ready"
|
||||
| "failed";
|
||||
|
||||
export interface SimulationSourceFile {
|
||||
fileId: string;
|
||||
logicalPath: string;
|
||||
byteLength: number;
|
||||
uploadedBytes: number;
|
||||
sha256: string | null;
|
||||
}
|
||||
|
||||
export interface SimulationWorldManifest {
|
||||
schemaVersion: "missioncore.simulation-world-manifest/v1";
|
||||
projectId: string;
|
||||
visual: {
|
||||
previewSogUrl: string | null;
|
||||
streamedSogUrl: string | null;
|
||||
};
|
||||
collision: {
|
||||
meshUrl: string | null;
|
||||
available: boolean;
|
||||
};
|
||||
transforms: {
|
||||
worldFromVisual: number[];
|
||||
worldFromCollision: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SimulationProject {
|
||||
schemaVersion: "missioncore.simulation-project/v1";
|
||||
projectId: string;
|
||||
name: string;
|
||||
sceneType: SimulationSceneType;
|
||||
status: SimulationProjectStatus;
|
||||
source: {
|
||||
kind: "archive" | "folder";
|
||||
totalByteLength: number;
|
||||
uploadedByteLength: number;
|
||||
files: SimulationSourceFile[];
|
||||
bundleSha256: string | null;
|
||||
};
|
||||
provider: {
|
||||
providerId: "gaussian-pipeline";
|
||||
jobId: string | null;
|
||||
state: string | null;
|
||||
progress: { completed_steps?: number; total_steps?: number; stage?: string } | null;
|
||||
runtime: { source_revision?: string; image_digest?: string } | null;
|
||||
};
|
||||
artifacts: Array<{
|
||||
role: string;
|
||||
logicalPath: string;
|
||||
mediaType: string;
|
||||
sha256: string;
|
||||
byteLength: number;
|
||||
}>;
|
||||
worldManifest: SimulationWorldManifest | null;
|
||||
error: string | null;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface SimulationUploadCandidate {
|
||||
file: File;
|
||||
logicalPath: string;
|
||||
}
|
||||
|
||||
export interface SimulationUploadProgress {
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
const API_ROOT = "/api/v1/simulation-worlds/projects";
|
||||
const CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_UPLOAD_ATTEMPTS = 3;
|
||||
|
||||
export async function fetchSimulationProjects(signal?: AbortSignal): Promise<SimulationProject[]> {
|
||||
const response = await fetch(API_ROOT, { signal, cache: "no-store" });
|
||||
const document = await jsonResponse(response);
|
||||
const record = objectValue(document, "simulation project page");
|
||||
exactKeys(record, ["schema_version", "projects"], "simulation project page");
|
||||
if (record.schema_version !== "missioncore.simulation-project-page/v1" || !Array.isArray(record.projects)) {
|
||||
throw new Error("Каталог симуляций вернул неподдерживаемый контракт.");
|
||||
}
|
||||
return record.projects.map(parseProject);
|
||||
}
|
||||
|
||||
export async function createAndUploadSimulationProject(
|
||||
name: string,
|
||||
sceneType: SimulationSceneType,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
onProgress: (progress: SimulationUploadProgress) => void,
|
||||
): Promise<SimulationProject> {
|
||||
const sourceKind = sourceKindFor(candidates);
|
||||
const createdResponse = await fetch(API_ROOT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.simulation-project-create/v1",
|
||||
name,
|
||||
scene_type: sceneType,
|
||||
source_kind: sourceKind,
|
||||
files: candidates.map((candidate) => ({
|
||||
logical_path: candidate.logicalPath,
|
||||
byte_length: candidate.file.size,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
let project = parseProject(await jsonResponse(createdResponse));
|
||||
const byPath = new Map(candidates.map((candidate) => [candidate.logicalPath, candidate.file]));
|
||||
const totalBytes = candidates.reduce((total, candidate) => total + candidate.file.size, 0);
|
||||
let confirmedBytes = 0;
|
||||
for (const sourceFile of project.source.files) {
|
||||
const file = byPath.get(sourceFile.logicalPath);
|
||||
if (!file) throw new Error(`Сервер изменил состав источника: ${sourceFile.logicalPath}`);
|
||||
const uploadUrl = `${API_ROOT}/${encodeURIComponent(project.projectId)}/source/${encodeURIComponent(sourceFile.fileId)}`;
|
||||
let offset = await readUploadOffset(uploadUrl, file.size);
|
||||
confirmedBytes += offset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
while (offset < file.size) {
|
||||
const nextOffset = Math.min(file.size, offset + CHUNK_BYTES);
|
||||
const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset);
|
||||
confirmedBytes += confirmedOffset - offset;
|
||||
offset = confirmedOffset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
}
|
||||
}
|
||||
const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(project.projectId)}/build`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
project = parseProject(await jsonResponse(buildResponse));
|
||||
return project;
|
||||
}
|
||||
|
||||
async function readUploadOffset(uploadUrl: string, fileSize: number): Promise<number> {
|
||||
const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store" });
|
||||
if (!head.ok) await jsonResponse(head);
|
||||
const value = head.headers.get("Upload-Offset");
|
||||
if (!value || !/^\d+$/.test(value)) {
|
||||
throw new Error("Сервер не вернул смещение resumable-загрузки.");
|
||||
}
|
||||
const offset = Number(value);
|
||||
if (!Number.isSafeInteger(offset) || offset < 0 || offset > fileSize) {
|
||||
throw new Error("Сервер вернул некорректное смещение загрузки.");
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
async function uploadChunk(
|
||||
uploadUrl: string,
|
||||
file: File,
|
||||
offset: number,
|
||||
nextOffset: number,
|
||||
): Promise<number> {
|
||||
let lastFailure: unknown = null;
|
||||
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
response = await fetch(uploadUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/offset+octet-stream",
|
||||
"Upload-Offset": String(offset),
|
||||
},
|
||||
body: file.slice(offset, nextOffset),
|
||||
});
|
||||
} catch (caught) {
|
||||
lastFailure = caught;
|
||||
}
|
||||
if (response?.ok) {
|
||||
const confirmed = response.headers.get("Upload-Offset");
|
||||
if (confirmed && Number(confirmed) === nextOffset) return nextOffset;
|
||||
throw new Error("Сервер не подтвердил непрерывность загрузки.");
|
||||
}
|
||||
if (
|
||||
response &&
|
||||
((response.status < 500 && response.status !== 409) ||
|
||||
(response.status >= 500 && attempt === MAX_UPLOAD_ATTEMPTS))
|
||||
) {
|
||||
await jsonResponse(response);
|
||||
throw new Error("Mission Core отклонил блок загрузки.");
|
||||
}
|
||||
if (response) {
|
||||
lastFailure = new Error(`Mission Core временно вернул HTTP ${response.status}.`);
|
||||
}
|
||||
try {
|
||||
const confirmed = await readUploadOffset(uploadUrl, file.size);
|
||||
if (confirmed === nextOffset) return confirmed;
|
||||
if (confirmed !== offset) {
|
||||
throw new Error("Сервер подтвердил неожиданное смещение загрузки.");
|
||||
}
|
||||
} catch (caught) {
|
||||
lastFailure = caught;
|
||||
}
|
||||
}
|
||||
throw lastFailure instanceof Error
|
||||
? lastFailure
|
||||
: new Error("Не удалось продолжить resumable-загрузку.");
|
||||
}
|
||||
|
||||
export async function updateSimulationProject(
|
||||
projectId: string,
|
||||
name: string,
|
||||
sceneType: SimulationSceneType,
|
||||
): Promise<SimulationProject> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.simulation-project-update/v1",
|
||||
name,
|
||||
scene_type: sceneType,
|
||||
}),
|
||||
});
|
||||
return parseProject(await jsonResponse(response));
|
||||
}
|
||||
|
||||
export async function deleteSimulationProject(projectId: string): Promise<void> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
||||
if (!response.ok) await jsonResponse(response);
|
||||
}
|
||||
|
||||
function sourceKindFor(candidates: SimulationUploadCandidate[]): "archive" | "folder" {
|
||||
if (candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "")) {
|
||||
return "archive";
|
||||
}
|
||||
if (candidates.some((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath))) {
|
||||
throw new Error("Архив загружается отдельно; нельзя смешивать его с файлами папки.");
|
||||
}
|
||||
return "folder";
|
||||
}
|
||||
|
||||
function parseProject(value: unknown): SimulationProject {
|
||||
const record = objectValue(value, "simulation project");
|
||||
exactKeys(record, [
|
||||
"schema_version", "project_id", "name", "scene_type", "status", "source", "provider",
|
||||
"artifacts", "world_manifest", "error", "created_at_utc", "updated_at_utc",
|
||||
], "simulation project");
|
||||
if (record.schema_version !== "missioncore.simulation-project/v1") {
|
||||
throw new Error("Проект симуляции вернул неподдерживаемую версию.");
|
||||
}
|
||||
const source = objectValue(record.source, "simulation source");
|
||||
const provider = objectValue(record.provider, "simulation provider");
|
||||
if (!Array.isArray(source.files) || !Array.isArray(record.artifacts)) {
|
||||
throw new Error("Проект симуляции вернул некорректный состав файлов.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.simulation-project/v1",
|
||||
projectId: stringValue(record.project_id, "project id"),
|
||||
name: stringValue(record.name, "project name"),
|
||||
sceneType: sceneTypeValue(record.scene_type),
|
||||
status: statusValue(record.status),
|
||||
source: {
|
||||
kind: source.kind === "archive" ? "archive" : source.kind === "folder" ? "folder" : invalid("source kind"),
|
||||
totalByteLength: numberValue(source.total_byte_length, "source bytes"),
|
||||
uploadedByteLength: numberValue(source.uploaded_byte_length, "uploaded bytes"),
|
||||
files: source.files.map((item) => {
|
||||
const file = objectValue(item, "source file");
|
||||
return {
|
||||
fileId: stringValue(file.file_id, "source file id"),
|
||||
logicalPath: stringValue(file.logical_path, "source logical path"),
|
||||
byteLength: numberValue(file.byte_length, "source file bytes"),
|
||||
uploadedBytes: numberValue(file.uploaded_bytes, "source uploaded bytes"),
|
||||
sha256: nullableString(file.sha256, "source sha256"),
|
||||
};
|
||||
}),
|
||||
bundleSha256: nullableString(source.bundle_sha256, "bundle sha256"),
|
||||
},
|
||||
provider: {
|
||||
providerId: provider.provider_id === "gaussian-pipeline" ? "gaussian-pipeline" : invalid("provider id"),
|
||||
jobId: nullableString(provider.job_id, "provider job id"),
|
||||
state: nullableString(provider.state, "provider state"),
|
||||
progress: provider.progress === null ? null : objectValue(provider.progress, "provider progress"),
|
||||
runtime: provider.runtime === null ? null : objectValue(provider.runtime, "provider runtime"),
|
||||
},
|
||||
artifacts: record.artifacts.map((item) => {
|
||||
const artifact = objectValue(item, "simulation artifact");
|
||||
return {
|
||||
role: stringValue(artifact.role, "artifact role"),
|
||||
logicalPath: stringValue(artifact.logical_path, "artifact path"),
|
||||
mediaType: stringValue(artifact.media_type, "artifact media type"),
|
||||
sha256: stringValue(artifact.sha256, "artifact sha256"),
|
||||
byteLength: numberValue(artifact.byte_length, "artifact bytes"),
|
||||
};
|
||||
}),
|
||||
worldManifest: record.world_manifest === null ? null : parseWorldManifest(record.world_manifest),
|
||||
error: nullableString(record.error, "project error"),
|
||||
createdAtUtc: stringValue(record.created_at_utc, "created at"),
|
||||
updatedAtUtc: stringValue(record.updated_at_utc, "updated at"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorldManifest(value: unknown): SimulationWorldManifest {
|
||||
const record = objectValue(value, "world manifest");
|
||||
const visual = objectValue(record.visual, "visual manifest");
|
||||
const collision = objectValue(record.collision, "collision manifest");
|
||||
const transforms = objectValue(record.transforms, "world transforms");
|
||||
if (record.schema_version !== "missioncore.simulation-world-manifest/v1") {
|
||||
throw new Error("World manifest вернул неподдерживаемую версию.");
|
||||
}
|
||||
return {
|
||||
schemaVersion: "missioncore.simulation-world-manifest/v1",
|
||||
projectId: stringValue(record.project_id, "manifest project id"),
|
||||
visual: {
|
||||
previewSogUrl: nullableString(visual.preview_sog_url, "preview URL"),
|
||||
streamedSogUrl: nullableString(visual.streamed_sog_url, "streamed URL"),
|
||||
},
|
||||
collision: {
|
||||
meshUrl: nullableString(collision.mesh_url, "collision URL"),
|
||||
available: booleanValue(collision.available, "collision availability"),
|
||||
},
|
||||
transforms: {
|
||||
worldFromVisual: matrixValue(transforms.world_from_visual, "visual transform"),
|
||||
worldFromCollision: matrixValue(transforms.world_from_collision, "collision transform"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function jsonResponse(response: Response): Promise<unknown> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch {
|
||||
if (response.ok) return {};
|
||||
throw new Error(`Mission Core вернул HTTP ${response.status}.`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = objectValue(value, "error response").detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `Mission Core вернул HTTP ${response.status}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, any> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
return value as Record<string, any>;
|
||||
}
|
||||
|
||||
function exactKeys(record: Record<string, any>, keys: string[], label: string): void {
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [...keys].sort();
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||
throw new Error(`Некорректные поля ${label}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value) throw new Error(`Некорректный ${label}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`Некорректный ${label}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function matrixValue(value: unknown, label: string): number[] {
|
||||
if (!Array.isArray(value) || value.length !== 16 || value.some((item) => typeof item !== "number")) {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
return [...value];
|
||||
}
|
||||
|
||||
function sceneTypeValue(value: unknown): SimulationSceneType {
|
||||
if (value === "interior" || value === "outdoor" || value === "object") return value;
|
||||
return invalid("scene type");
|
||||
}
|
||||
|
||||
function statusValue(value: unknown): SimulationProjectStatus {
|
||||
if (["uploading", "queued", "processing", "importing", "ready", "failed"].includes(String(value))) {
|
||||
return value as SimulationProjectStatus;
|
||||
}
|
||||
return invalid("project status");
|
||||
}
|
||||
|
||||
function invalid(label: string): never {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
@@ -22,7 +22,8 @@ export type WorkspaceKind =
|
||||
| "network-monitor"
|
||||
| "datasets"
|
||||
| "artifact-health"
|
||||
| "lab-archive";
|
||||
| "lab-archive"
|
||||
| "simulations";
|
||||
|
||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||
|
||||
@@ -154,6 +155,17 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
kind: "lab-archive",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "simulations",
|
||||
root: "polygon",
|
||||
label: "Симуляции",
|
||||
title: "Симуляции",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ",
|
||||
description: "Каталог Gaussian-миров, переносимые сборки и интерактивные PlayCanvas-сцены.",
|
||||
icon: "globe",
|
||||
kind: "simulations",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "contour-health",
|
||||
root: "fleet",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@import "./styles/l3-pointpillars-visual-audit.css";
|
||||
@import "./styles/l34-annotation.css";
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/simulation.css";
|
||||
@import "./styles/laboratory-evidence-report.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/m4-replay-threat.css";
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
.simulation-workspace {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.9rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.simulation-workspace--scene {
|
||||
height: 100%;
|
||||
min-height: 38rem;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.simulation-workspace__head,
|
||||
.simulation-workspace__scene-head,
|
||||
.simulation-viewport__toolbar,
|
||||
.simulation-viewport__notice,
|
||||
.simulation-catalog__summary,
|
||||
.simulation-catalog__state,
|
||||
.simulation-catalog__actions,
|
||||
.simulation-project-window__footer-state,
|
||||
.simulation-source-drop > div,
|
||||
.simulation-upload-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.simulation-workspace__head,
|
||||
.simulation-workspace__scene-head {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.25rem 0.2rem;
|
||||
}
|
||||
|
||||
.simulation-workspace__head h2,
|
||||
.simulation-workspace__head p,
|
||||
.simulation-workspace__scene-head h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.simulation-workspace__head h2,
|
||||
.simulation-workspace__scene-head h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.18rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.simulation-workspace__head p {
|
||||
max-width: 46rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.simulation-workspace__scene-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, auto) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.simulation-catalog {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.022);
|
||||
}
|
||||
|
||||
.simulation-catalog__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
background: var(--station-hairline);
|
||||
}
|
||||
|
||||
.simulation-catalog__summary > div {
|
||||
display: grid;
|
||||
gap: 0.24rem;
|
||||
background: var(--nodedc-canvas);
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__summary span,
|
||||
.simulation-catalog__table th {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
letter-spacing: 0.075em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.simulation-catalog__summary strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.simulation-catalog__table {
|
||||
width: 100%;
|
||||
min-width: 62rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.simulation-catalog__table th,
|
||||
.simulation-catalog__table td {
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
padding: 0.72rem 0.8rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.simulation-catalog__table th:first-child,
|
||||
.simulation-catalog__table td:first-child {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table th:last-child,
|
||||
.simulation-catalog__table td:last-child {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table tbody tr {
|
||||
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.simulation-catalog__table tbody tr:hover {
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
|
||||
.simulation-catalog__table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td > small,
|
||||
.simulation-catalog__table td > button small {
|
||||
display: block;
|
||||
margin-top: 0.18rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td:first-child > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 14rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td:first-child > button strong {
|
||||
display: block;
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.simulation-catalog__project-icon,
|
||||
.simulation-catalog__empty > span {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: 0.62rem;
|
||||
background: rgb(var(--nodedc-accent-rgb) / 0.07);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.simulation-catalog__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__state,
|
||||
.simulation-catalog__empty {
|
||||
min-height: 20rem;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__state > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__state strong,
|
||||
.simulation-catalog__empty strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.simulation-catalog__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-catalog__empty p {
|
||||
max-width: 30rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.simulation-project-window {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.simulation-project-window .nodedc-select-anchor,
|
||||
.simulation-project-window .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.simulation-source-drop {
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.55rem;
|
||||
border: 1px dashed var(--nodedc-glass-outline);
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.018);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
transition: border-color var(--nodedc-duration-fast) var(--nodedc-ease-standard),
|
||||
background var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.simulation-source-drop[data-dragging="true"] {
|
||||
border-color: rgb(var(--nodedc-accent-rgb) / 0.72);
|
||||
background: rgb(var(--nodedc-accent-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.simulation-source-drop > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.simulation-source-drop > span {
|
||||
max-width: 30rem;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.simulation-source-drop > div {
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.simulation-upload-progress {
|
||||
gap: 0.7rem;
|
||||
border-radius: 0.72rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.simulation-upload-progress > span {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 8rem;
|
||||
height: 0.28rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.simulation-upload-progress > span i {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: inherit;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.simulation-upload-progress > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.14rem;
|
||||
}
|
||||
|
||||
.simulation-upload-progress strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.simulation-upload-progress small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.simulation-project-window__footer-state {
|
||||
gap: 0.4rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.simulation-project-window__error {
|
||||
margin: 0;
|
||||
border-radius: 0.65rem;
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.08);
|
||||
color: var(--nodedc-danger);
|
||||
padding: 0.65rem 0.75rem;
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.simulation-viewport {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: 1rem;
|
||||
background: #07090d;
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar > div:first-child {
|
||||
display: flex;
|
||||
min-width: 12rem;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar > div:first-child > span:last-child,
|
||||
.simulation-viewport__notice > span:last-child {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage {
|
||||
position: relative;
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.7);
|
||||
}
|
||||
|
||||
.simulation-viewport__state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.55rem;
|
||||
background: rgb(7 9 13 / 0.84);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-viewport__state strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__state p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__notice {
|
||||
gap: 0.55rem;
|
||||
border-top: 1px solid var(--station-hairline);
|
||||
padding: 0.48rem 0.65rem;
|
||||
}
|
||||
|
||||
.simulation-workspace__processing {
|
||||
display: flex;
|
||||
min-height: 18rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.simulation-workspace__processing > div {
|
||||
display: grid;
|
||||
max-width: 40rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.simulation-workspace__processing strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.simulation-workspace__processing p {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.simulation-catalog__summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.simulation-workspace__scene-head {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.simulation-workspace__scene-head > .nodedc-button {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
declare module "playcanvas/scripts/esm/camera-controls.mjs" {
|
||||
import { Script, Vec2, Vec3 } from "playcanvas";
|
||||
|
||||
export class CameraControls extends Script {
|
||||
static scriptName: "cameraControls";
|
||||
enableFly: boolean;
|
||||
enableOrbit: boolean;
|
||||
focusPoint: Vec3;
|
||||
zoomRange: Vec2;
|
||||
focus(focus: Vec3, resetZoom?: boolean): void;
|
||||
look(focus: Vec3, resetZoom?: boolean): void;
|
||||
reset(focus: Vec3, position: Vec3): void;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
@@ -41,6 +36,7 @@ import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||
import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
|
||||
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
|
||||
import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
|
||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
||||
@@ -1193,6 +1189,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
SpatialView={SpatialWorkspace}
|
||||
/>
|
||||
);
|
||||
case "simulations":
|
||||
return <SimulationWorkspace />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
ConfirmationModal,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { SimulationProjectWindow } from "../../components/simulation/SimulationProjectWindow";
|
||||
import { SimulationViewport } from "../../components/simulation/SimulationViewport";
|
||||
import {
|
||||
deleteSimulationProject,
|
||||
fetchSimulationProjects,
|
||||
type SimulationProject,
|
||||
type SimulationProjectStatus,
|
||||
} from "../../core/simulation/projects";
|
||||
|
||||
const ACTIVE_STATUSES = new Set<SimulationProjectStatus>(["queued", "processing", "importing"]);
|
||||
|
||||
export function SimulationWorkspace() {
|
||||
const [projects, setProjects] = useState<SimulationProject[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [windowOpen, setWindowOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<SimulationProject | null>(null);
|
||||
const [deleting, setDeleting] = useState<SimulationProject | null>(null);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
try {
|
||||
const next = await fetchSimulationProjects(signal);
|
||||
setProjects(next);
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
if (signal?.aborted) return;
|
||||
setError(caught instanceof Error ? caught.message : "Каталог симуляций недоступен.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projects.some((project) => ACTIVE_STATUSES.has(project.status))) return;
|
||||
const timer = window.setInterval(() => void load(), 2_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, projects]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => projects.find((project) => project.projectId === selectedId) ?? null,
|
||||
[projects, selectedId],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setWindowOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (project: SimulationProject) => {
|
||||
setEditing(project);
|
||||
setWindowOpen(true);
|
||||
};
|
||||
|
||||
const acceptSaved = (project: SimulationProject) => {
|
||||
setProjects((current) => [project, ...current.filter((item) => item.projectId !== project.projectId)]);
|
||||
setSelectedId(project.projectId);
|
||||
setWindowOpen(false);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleting) return;
|
||||
await deleteSimulationProject(deleting.projectId);
|
||||
setProjects((current) => current.filter((project) => project.projectId !== deleting.projectId));
|
||||
if (selectedId === deleting.projectId) setSelectedId(null);
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="simulation-workspace simulation-workspace--scene">
|
||||
<header className="simulation-workspace__scene-head">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
icon={<Icon name="chevron-left" size={16} />}
|
||||
onClick={() => setSelectedId(null)}
|
||||
>
|
||||
Все проекты
|
||||
</Button>
|
||||
<div>
|
||||
<span className="section-eyebrow">LAB / SIMULATION RUNTIME</span>
|
||||
<h2>{selected.name}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={statusPresentation(selected.status).tone}>
|
||||
{statusPresentation(selected.status).label}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
{selected.status === "ready" && selected.worldManifest ? (
|
||||
<SimulationViewport project={selected} />
|
||||
) : (
|
||||
<GlassSurface className="simulation-workspace__processing" padding="lg">
|
||||
{selected.status === "failed" ? <Icon name="alert" size={20} /> : <ActivityIndicator label="Сборка мира" />}
|
||||
<div>
|
||||
<strong>{selected.status === "failed" ? "Сборка остановлена" : "Worker 006 собирает мир"}</strong>
|
||||
<p>{selected.error ?? processingMessage(selected)}</p>
|
||||
</div>
|
||||
<StatusBadge tone={selected.status === "failed" ? "warning" : "accent"}>
|
||||
{selected.provider.state ?? selected.status}
|
||||
</StatusBadge>
|
||||
</GlassSurface>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="simulation-workspace">
|
||||
<header className="simulation-workspace__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ</span>
|
||||
<h2>Gaussian-миры</h2>
|
||||
<p>Каталог исходников, сборок Worker 006 и полнофункциональных PlayCanvas-сцен.</p>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Icon name="plus" size={16} />} onClick={openCreate}>
|
||||
Новый проект
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<section className="simulation-catalog" aria-label="Проекты симуляции">
|
||||
<header className="simulation-catalog__summary">
|
||||
<div><span>Проектов</span><strong>{projects.length.toLocaleString("ru-RU")}</strong></div>
|
||||
<div><span>Готово</span><strong>{projects.filter((project) => project.status === "ready").length}</strong></div>
|
||||
<div><span>В работе</span><strong>{projects.filter((project) => ACTIVE_STATUSES.has(project.status)).length}</strong></div>
|
||||
<div><span>Исходники</span><strong>{formatBytes(projects.reduce((sum, project) => sum + project.source.totalByteLength, 0))}</strong></div>
|
||||
</header>
|
||||
{loading ? (
|
||||
<div className="simulation-catalog__state" role="status">
|
||||
<ActivityIndicator label="Загрузка каталога" />
|
||||
<span>Читаем каталог сцен</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="simulation-catalog__state" role="alert">
|
||||
<Icon name="alert" size={20} />
|
||||
<div><strong>Каталог недоступен</strong><span>{error}</span></div>
|
||||
<Button size="compact" onClick={() => void load()}>Повторить</Button>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="simulation-catalog__empty">
|
||||
<span><Icon name="globe" size={20} /></span>
|
||||
<strong>Проектов пока нет</strong>
|
||||
<p>Создайте первый мир из папки LCC/LCC2 или архива ZIP, RAR, 7z.</p>
|
||||
<Button variant="primary" icon={<Icon name="upload" size={16} />} onClick={openCreate}>
|
||||
Загрузить исходник
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="simulation-catalog__table-wrap">
|
||||
<table className="simulation-catalog__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Проект</th>
|
||||
<th>Источник</th>
|
||||
<th>Размер</th>
|
||||
<th>Сплаты</th>
|
||||
<th>Статус</th>
|
||||
<th>Обновлён</th>
|
||||
<th aria-label="Действия" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => {
|
||||
const status = statusPresentation(project.status);
|
||||
return (
|
||||
<tr key={project.projectId} data-status={project.status}>
|
||||
<td>
|
||||
<button type="button" onClick={() => setSelectedId(project.projectId)}>
|
||||
<span className="simulation-catalog__project-icon"><Icon name="globe" size={18} /></span>
|
||||
<span><strong>{project.name}</strong><small>{sceneTypeLabel(project.sceneType)}</small></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{project.source.kind === "archive" ? "Архив" : "Папка"}<small>{project.source.files.length} файл(ов)</small></td>
|
||||
<td>{formatBytes(project.source.totalByteLength)}</td>
|
||||
<td>—</td>
|
||||
<td><StatusBadge tone={status.tone}>{status.label}</StatusBadge></td>
|
||||
<td>{formatDate(project.updatedAtUtc)}</td>
|
||||
<td>
|
||||
<div className="simulation-catalog__actions">
|
||||
<IconButton label={`Удалить ${project.name}`} disabled={ACTIVE_STATUSES.has(project.status)} onClick={() => setDeleting(project)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label={`Редактировать ${project.name}`} onClick={() => openEdit(project)}>
|
||||
<Icon name="edit" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SimulationProjectWindow
|
||||
open={windowOpen}
|
||||
project={editing}
|
||||
onClose={() => { if (!windowOpen) return; setWindowOpen(false); setEditing(null); }}
|
||||
onSaved={acceptSaved}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
open={deleting !== null}
|
||||
title="Удалить проект симуляции?"
|
||||
description={(
|
||||
<p>
|
||||
Проект <strong>{deleting?.name}</strong>, локальные производные артефакты и job на Worker 006 будут удалены.
|
||||
</p>
|
||||
)}
|
||||
confirmLabel="Удалить проект"
|
||||
pendingLabel="Удаляем…"
|
||||
danger
|
||||
onClose={() => setDeleting(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusPresentation(status: SimulationProjectStatus): {
|
||||
label: string;
|
||||
tone: "success" | "accent" | "warning" | "neutral";
|
||||
} {
|
||||
if (status === "ready") return { label: "Готово", tone: "success" };
|
||||
if (status === "failed") return { label: "Ошибка", tone: "warning" };
|
||||
if (status === "uploading") return { label: "Загрузка", tone: "neutral" };
|
||||
if (status === "importing") return { label: "Импорт", tone: "accent" };
|
||||
return { label: "Сборка", tone: "accent" };
|
||||
}
|
||||
|
||||
function processingMessage(project: SimulationProject): string {
|
||||
const progress = project.provider.progress;
|
||||
const completed = progress?.completed_steps;
|
||||
const total = progress?.total_steps;
|
||||
if (typeof completed === "number" && typeof total === "number") {
|
||||
return `Этап ${completed.toLocaleString("ru-RU")} из ${total.toLocaleString("ru-RU")}. Каталог обновляется автоматически.`;
|
||||
}
|
||||
return "Исходник подтверждён; конвертация SOG и Streamed SOG выполняется в переносимом контейнере.";
|
||||
}
|
||||
|
||||
function sceneTypeLabel(value: SimulationProject["sceneType"]): string {
|
||||
if (value === "interior") return "Интерьер";
|
||||
if (value === "object") return "Объект";
|
||||
return "Улица";
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.valueOf()) ? "—" : date.toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} Б`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`;
|
||||
return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`;
|
||||
}
|
||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive"],
|
||||
["lab-archive", "simulations"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const sourceRoot = new URL("../src/", import.meta.url);
|
||||
|
||||
async function read(relativePath) {
|
||||
return readFile(new URL(relativePath, sourceRoot), "utf8");
|
||||
}
|
||||
|
||||
test("simulation is a dedicated Polygon workspace with a bounded feature slice", async () => {
|
||||
const [productModel, workspaceHub, workspace, styles] = await Promise.all([
|
||||
read("productModel.ts"),
|
||||
read("workspaces/Workspaces.tsx"),
|
||||
read("workspaces/simulation/SimulationWorkspace.tsx"),
|
||||
read("styles.css"),
|
||||
]);
|
||||
|
||||
assert.match(productModel, /id: "simulations"[\s\S]*root: "polygon"[\s\S]*kind: "simulations"/);
|
||||
assert.match(workspaceHub, /case "simulations":[\s\S]*<SimulationWorkspace \/>/);
|
||||
assert.match(workspace, /simulation-catalog__table/);
|
||||
assert.match(workspace, /<SimulationViewport project=\{selected\}/);
|
||||
assert.match(workspace, /<ConfirmationModal/);
|
||||
assert.match(styles, /styles\/simulation\.css/);
|
||||
});
|
||||
|
||||
test("browser upload remains same-origin, resumable and token-free", async () => {
|
||||
const [core, window] = await Promise.all([
|
||||
read("core/simulation/projects.ts"),
|
||||
read("components/simulation/SimulationProjectWindow.tsx"),
|
||||
]);
|
||||
|
||||
assert.match(core, /const API_ROOT = "\/api\/v1\/simulation-worlds\/projects"/);
|
||||
assert.match(core, /method: "HEAD"/);
|
||||
assert.match(core, /method: "PATCH"/);
|
||||
assert.match(core, /"Upload-Offset"/);
|
||||
assert.doesNotMatch(core, /Authorization|Bearer|18090|Worker 006/);
|
||||
assert.match(window, /accept="\.zip,\.rar,\.7z"/);
|
||||
assert.match(window, /webkitdirectory/);
|
||||
assert.match(window, /webkitGetAsEntry/);
|
||||
assert.match(window, /ровно одна сцена \.lcc или \.lcc2/);
|
||||
});
|
||||
|
||||
test("PlayCanvas owns the realtime scene graph without an iframe or React entity tree", async () => {
|
||||
const [runtime, viewport, packageDocument] = await Promise.all([
|
||||
read("components/simulation/PlayCanvasRuntime.ts"),
|
||||
read("components/simulation/SimulationViewport.tsx"),
|
||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(packageDocument, /"playcanvas": "2\.21\.4"/);
|
||||
assert.doesNotMatch(packageDocument, /@playcanvas\/react/);
|
||||
assert.match(runtime, /new Application\(canvas/);
|
||||
assert.match(runtime, /new Asset\([^,]+, "gsplat"/);
|
||||
assert.match(runtime, /camera\.script\?\.create\(CameraControls/);
|
||||
assert.match(runtime, /app\.root\.addChild/);
|
||||
assert.match(runtime, /dispose\(\)/);
|
||||
assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe|<GSplat/);
|
||||
assert.match(viewport, /Collision недоступен/);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schema_version": "missioncore.reference-perception-graph-config/v2",
|
||||
"graph_id": "reference-perception-graph/v2",
|
||||
"source_profile_id": "m4-ravnoves00-recorded-realtime/v1",
|
||||
"providers": [
|
||||
{
|
||||
"role": "source",
|
||||
"provider_id": "ravnoves00-recorded-source/v1",
|
||||
"version": "1.0.0",
|
||||
"revision": "m4-ravnoves00-recorded-realtime/v1",
|
||||
"sha256": "ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||
},
|
||||
{
|
||||
"role": "detector",
|
||||
"provider_id": "triton-rf-detr-large-coco-native-kb4-risk-fp16-shadow/v0",
|
||||
"version": "0.1.0",
|
||||
"revision": "rf-detr-large-coco-native-kb4-uint8-trt11-fp16-risk-shadow/v0",
|
||||
"sha256": "398b1102943e704b08a033b1d04b6bdb039ecdd73a2b2e370a0a9cbcaff501ec"
|
||||
},
|
||||
{
|
||||
"role": "geometry",
|
||||
"provider_id": "ravnoves00-geometry-association/v1",
|
||||
"version": "1.0.0",
|
||||
"revision": "m4-ravnoves00-e29-e32-geometry/v1",
|
||||
"sha256": "cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"
|
||||
},
|
||||
{
|
||||
"role": "temporal",
|
||||
"provider_id": "bounded-spatial-temporal-layer/v1",
|
||||
"version": "1.0.0",
|
||||
"revision": "m4-bounded-temporal-motion/v1",
|
||||
"sha256": "7130eaee24a95c7d888bf7598010e03e129e1c3ac5b34bcd8401015ff4244b39"
|
||||
},
|
||||
{
|
||||
"role": "motion",
|
||||
"provider_id": "class-independent-motion-estimator/v1",
|
||||
"version": "1.0.0",
|
||||
"revision": "m4-bounded-temporal-motion/v1",
|
||||
"sha256": "7130eaee24a95c7d888bf7598010e03e129e1c3ac5b34bcd8401015ff4244b39"
|
||||
},
|
||||
{
|
||||
"role": "rolling",
|
||||
"provider_id": "rolling-local-obstacle-map/v1",
|
||||
"version": "1.0.0",
|
||||
"revision": "ravnoves00-rolling-local-obstacle-map/v1",
|
||||
"sha256": "f7e3315eaf6ffaf3aee1e04913933812092cf82bbcc9984c1a6fa2d9250e6784"
|
||||
},
|
||||
{
|
||||
"role": "threat",
|
||||
"provider_id": "dual-evidence-replay-threat/v3",
|
||||
"version": "3.0.0",
|
||||
"revision": "m4-ravnoves00-virtual-corridor/v3",
|
||||
"sha256": "8c3a5aa837da1f028f5998fb504a1381f9b2b68de6420a32160410b6dc0887c7"
|
||||
}
|
||||
],
|
||||
"queues": [
|
||||
{
|
||||
"stage_id": "detector",
|
||||
"capacity": 2,
|
||||
"deadline_ns": 1000000000,
|
||||
"terminal_timeout_ns": 90000000000
|
||||
},
|
||||
{
|
||||
"stage_id": "geometry",
|
||||
"capacity": 2,
|
||||
"deadline_ns": 1500000000,
|
||||
"terminal_timeout_ns": 90000000000
|
||||
},
|
||||
{
|
||||
"stage_id": "temporal",
|
||||
"capacity": 2,
|
||||
"deadline_ns": 1750000000,
|
||||
"terminal_timeout_ns": 90000000000
|
||||
},
|
||||
{
|
||||
"stage_id": "rolling",
|
||||
"capacity": 2,
|
||||
"deadline_ns": 2000000000,
|
||||
"terminal_timeout_ns": 90000000000
|
||||
},
|
||||
{
|
||||
"stage_id": "threat",
|
||||
"capacity": 2,
|
||||
"deadline_ns": 2250000000,
|
||||
"terminal_timeout_ns": 90000000000
|
||||
}
|
||||
],
|
||||
"authority": {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"schema_version": "missioncore.rf-detr-native-risk-shadow-profile/v0",
|
||||
"profile_id": "rf-detr-large-coco-native-kb4-uint8-trt11-fp16-risk-shadow/v0",
|
||||
"provider_id": "triton-rf-detr-large-coco-native-kb4-risk-fp16-shadow/v0",
|
||||
"model": {
|
||||
"model_id": "rf_detr_large_native_kb4",
|
||||
"model_version": 1,
|
||||
"upstream_version": "1.9.4",
|
||||
"upstream_revision": "9b009fa928d6218320439803d1da01869a85c072",
|
||||
"checkpoint_sha256": "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38",
|
||||
"native_core_onnx_sha256": "62e549748a1d17646b90ad06d3ac8a1b79595b7e9270cac4564418f023079176",
|
||||
"strongly_typed_fp16_onnx_sha256": "00b29fa2ff3d5fca730ebf3b8c33b10e9d690cc97bbbbfa5563d6e0abf210999",
|
||||
"fused_uint8_onnx_sha256": "acdd01623a00d100331473c0a99eab1e5adf33117cbab900c8ae078edd4aa346",
|
||||
"worker_006_rtx4090_tensorrt_11_engine_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
|
||||
"input": {
|
||||
"name": "raw_kb4_bgr",
|
||||
"datatype": "UINT8",
|
||||
"shape": [1, 600, 800, 3],
|
||||
"bytes_per_frame": 1440000
|
||||
},
|
||||
"outputs": [
|
||||
{"name": "dets", "datatype": "FP16", "shape": [1, 300, 4]},
|
||||
{"name": "labels", "datatype": "FP16", "shape": [1, 300, 91]}
|
||||
]
|
||||
},
|
||||
"preprocessing": {
|
||||
"execution": "single-fused-tensorrt-gpu-graph",
|
||||
"source_raster": [800, 600],
|
||||
"source_color": "BGR",
|
||||
"model_canvas": [800, 608],
|
||||
"model_color": "RGB",
|
||||
"valid_fov_mask_sha256": "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63",
|
||||
"valid_fov_fill_value": 114,
|
||||
"padding_tblr": [0, 8, 0, 0],
|
||||
"normalization_mean": [0.485, 0.456, 0.406],
|
||||
"normalization_std": [0.229, 0.224, 0.225],
|
||||
"resize": false,
|
||||
"crop": false,
|
||||
"rectification": false,
|
||||
"warp": false,
|
||||
"geometric_resampling": false
|
||||
},
|
||||
"emission": {
|
||||
"single_inference_per_source_frame": true,
|
||||
"minimum_score": 0.25,
|
||||
"maximum_topk_query_class_pairs": 300,
|
||||
"behavior_relevant_classes": [
|
||||
"person",
|
||||
"bicycle",
|
||||
"car",
|
||||
"motorcycle",
|
||||
"bus",
|
||||
"truck",
|
||||
"bird",
|
||||
"cat",
|
||||
"dog",
|
||||
"horse",
|
||||
"sheep",
|
||||
"cow",
|
||||
"elephant",
|
||||
"bear",
|
||||
"zebra",
|
||||
"giraffe",
|
||||
"skateboard"
|
||||
],
|
||||
"geometry_owns_static_occupancy": true,
|
||||
"unlisted_semantic_classes_emitted": false,
|
||||
"minimum_box_area_pixels": 64,
|
||||
"maximum_box_area_fraction": 0.5,
|
||||
"minimum_valid_fov_fraction": 0.5,
|
||||
"require_center_inside_valid_fov": true
|
||||
},
|
||||
"qualification": {
|
||||
"native_pytorch_tensorrt_parity": {
|
||||
"report_identity_sha256": "215145fed04b43670a71594a7ea6d8f2a676f2ee781eb1d0bfc05bb3ecdbf17c",
|
||||
"passed": true,
|
||||
"risk_detection_precision": 0.988700565,
|
||||
"risk_detection_recall": 0.983146067,
|
||||
"matched_mean_iou": 0.988242066
|
||||
},
|
||||
"full_ravnoves00_native_vs_legacy_704": {
|
||||
"report_identity_sha256": "729bf02b5b52b22d347b3c960f3ac01539ebc76569273bd85560fed8d7b7616b",
|
||||
"frame_count": 4489,
|
||||
"native_total_mean_ms": 12.334212,
|
||||
"native_total_p95_ms": 19.730654,
|
||||
"legacy_704_total_mean_ms": 33.995721,
|
||||
"legacy_704_total_p95_ms": 47.432686,
|
||||
"transport_bytes_reduction_fraction": 0.757877066,
|
||||
"native_detection_count": 48583,
|
||||
"legacy_704_detection_count": 51691,
|
||||
"legacy_box_agreement_recall_iou_at_least_0_5": 0.766593798,
|
||||
"legacy_box_agreement_gate_passed": false,
|
||||
"interpretation": "diagnostic-only because legacy 704 geometrically stretches the raw 4:3 raster"
|
||||
}
|
||||
},
|
||||
"queue": {
|
||||
"policy": "bounded-latest-wins",
|
||||
"capacity": 2
|
||||
},
|
||||
"status": {
|
||||
"native_tensor_parity_passed": true,
|
||||
"full_ravnoves00_runtime_gate_passed": true,
|
||||
"legacy_704_box_agreement_gate_passed": false,
|
||||
"integrated_world_state_gate_passed": false,
|
||||
"production_accepted": false
|
||||
},
|
||||
"authority": {
|
||||
"ground_truth": false,
|
||||
"candidate_accepted": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# Simulation Worlds — product-surface brief
|
||||
|
||||
Status: accepted for the first working vertical slice on 2026-08-26.
|
||||
|
||||
## Job story
|
||||
|
||||
When an operator has a computed XGRIDS export, they need to create a named simulation project from
|
||||
one LCC/LCC2 folder or one ZIP/RAR/7z archive, observe the real Worker 006 build state, and open the
|
||||
result as an interactive PlayCanvas scene. The scene is a future simulation runtime, not a terminal
|
||||
Gaussian viewer: vehicles, scripts, sensors, physics and debug layers must be able to join the same
|
||||
`pc.Application` later without replacing the product surface.
|
||||
|
||||
## Placement decision
|
||||
|
||||
The explicit product decision is `Тестировочный контур → Симуляции`, as a sibling of
|
||||
`Лабораторные контуры`.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
1. Extend `Лабораторные контуры`. Rejected because that surface owns reproducible experiment
|
||||
evidence and selectors, while a simulation world has its own project, upload, build and deletion
|
||||
lifecycle.
|
||||
2. Add a dedicated workspace under `Тестировочный контур`. Selected because it preserves the Lab
|
||||
root while allowing table-first catalog and scene-first runtime compositions.
|
||||
3. Place the feature under `Система`. Rejected because Worker/provider health is a system concern,
|
||||
but creating and operating worlds is an operator task.
|
||||
|
||||
The workspace is a real `productModel` entry. It is not an internal route, iframe or generic
|
||||
capability placeholder.
|
||||
|
||||
## Composition and component ownership
|
||||
|
||||
The catalog is table-first: project identity, source kind, byte size, splat count when actually
|
||||
available, build status, updated time, edit and delete. A selected ready project switches to a
|
||||
scene-first composition with the PlayCanvas canvas and bounded runtime controls.
|
||||
|
||||
Canonical Design Guideline entities are reused for buttons, icon buttons, status badges, fields,
|
||||
selects, segmented controls, confirmation, window, activity and glass surfaces. The semantic
|
||||
project table, upload-source target, progress row and PlayCanvas stage are feature-owned renderers;
|
||||
they are not introduced as parallel generic UI primitives.
|
||||
|
||||
## State and ownership boundaries
|
||||
|
||||
```text
|
||||
React workspace
|
||||
├─ project catalog and modal state
|
||||
├─ same-origin resumable upload
|
||||
└─ selected immutable WorldManifest
|
||||
└─ PlayCanvasRuntime
|
||||
└─ pc.Application / realtime entity graph
|
||||
|
||||
Mission Core backend
|
||||
├─ durable project metadata and source staging
|
||||
├─ provider orchestration and artifact import
|
||||
└─ same-origin immutable artifact delivery
|
||||
|
||||
DC Gaussian Pipeline
|
||||
├─ TUS bundle or archive admission
|
||||
├─ secure ZIP/RAR/7z normalization
|
||||
└─ SplatTransform build on Worker 006
|
||||
```
|
||||
|
||||
The browser never receives the Worker token. Mission Core does not embed archive-format behavior
|
||||
in its product domain. The portable pipeline normalizes archives into the same immutable LCC/LCC2
|
||||
bundle contract used by folder uploads.
|
||||
|
||||
## Runtime decision
|
||||
|
||||
Mission Core pins the direct `playcanvas` package. `PlayCanvasRuntime` owns one `pc.Application`,
|
||||
camera controls, GSplat asset lifecycle and later physical/debug entities. React owns no per-frame
|
||||
entity state. A new world is data and does not require a new frontend build.
|
||||
|
||||
The first Worker 006 provider is CPU-only and therefore does not advertise collision outputs. The
|
||||
UI disables collision/combined modes and states this explicitly; it does not synthesize a proxy.
|
||||
The manifest already keeps visual and collision URLs and their independent world transforms so the
|
||||
GPU collision build can be admitted without changing the project surface.
|
||||
|
||||
Primary implementation references:
|
||||
|
||||
- [PlayCanvas Engine](https://github.com/playcanvas/engine)
|
||||
- [GSplatComponent API](https://api.playcanvas.com/engine/classes/GSplatComponent.html)
|
||||
- [Streamed SOG runtime](https://developer.playcanvas.com/user-manual/gaussian-splatting/building/lod-streaming/)
|
||||
- [SplatTransform](https://developer.playcanvas.com/user-manual/splat-transform/)
|
||||
- [SplatTransform Docker](https://developer.playcanvas.com/user-manual/splat-transform/docker/)
|
||||
- [SplatTransform collision](https://developer.playcanvas.com/user-manual/splat-transform/collision/)
|
||||
|
||||
## First-slice acceptance
|
||||
|
||||
- archive or folder selection does not depend on the parent folder name;
|
||||
- folder input contains exactly one `.lcc` or `.lcc2` descriptor;
|
||||
- ZIP, RAR and 7z are uploaded resumably and normalized only inside the pipeline container;
|
||||
- encrypted, linked, traversing, duplicate and over-limit archive entries fail closed;
|
||||
- project status is durable and reflects provider state without fabricated percentages;
|
||||
- ready artifacts are imported digest-bound and served from Mission Core same-origin URLs;
|
||||
- edit changes project metadata; delete removes both the Mission Core project and terminal provider
|
||||
job;
|
||||
- a ready project mounts direct PlayCanvas Engine and loads Streamed SOG with preview fallback;
|
||||
- visual, collision and combined remain distinct runtime modes even when collision is not yet
|
||||
available on the CPU provider.
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert the native 608x800 RF-DETR core to a strongly typed FP16 graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import onnx # type: ignore[import-not-found]
|
||||
from onnx import TensorProto
|
||||
from onnxconverter_common import float16 # type: ignore[import-not-found]
|
||||
|
||||
SCHEMA_VERSION: Final = "missioncore.m48n-rf-detr-native-onnx-fp16-conversion/v0"
|
||||
PROFILE_ID: Final = "rf-detr-large-coco-native-kb4-608x800-trt11-fp16/v0"
|
||||
FALSE_AUTHORITY: Final = {
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", type=Path, required=True)
|
||||
parser.add_argument("--expected-input-sha256", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
source = arguments.input.resolve(strict=True)
|
||||
source_sha256 = sha256_path(source)
|
||||
if source_sha256 != arguments.expected_input_sha256:
|
||||
raise RuntimeError("source ONNX SHA-256 does not match the export manifest")
|
||||
output = arguments.output.absolute()
|
||||
manifest = arguments.manifest.absolute()
|
||||
if output.exists() or manifest.exists():
|
||||
raise RuntimeError("native FP16 ONNX output or manifest already exists")
|
||||
|
||||
started_utc_ns = time.time_ns()
|
||||
graph = onnx.load(str(source))
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r"the float32 number .* will be truncated to .*",
|
||||
category=UserWarning,
|
||||
module=r"onnxconverter_common\.float16",
|
||||
)
|
||||
converted = float16.convert_float_to_float16(
|
||||
graph,
|
||||
keep_io_types=False,
|
||||
disable_shape_infer=False,
|
||||
)
|
||||
retargeted_casts = _retarget_float_casts_to_fp16(converted)
|
||||
_insert_fp32_input_cast(converted)
|
||||
onnx.checker.check_model(converted)
|
||||
output.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
onnx.save(converted, str(output))
|
||||
verified = onnx.load(str(output), load_external_data=False)
|
||||
onnx.checker.check_model(verified)
|
||||
inputs = [_tensor_description(value) for value in verified.graph.input]
|
||||
outputs = [_tensor_description(value) for value in verified.graph.output]
|
||||
expected_input = [
|
||||
{"name": "input", "element_type": TensorProto.FLOAT, "shape": [1, 3, 608, 800]}
|
||||
]
|
||||
if inputs != expected_input:
|
||||
raise RuntimeError(f"native FP16 ONNX input boundary changed: {inputs}")
|
||||
expected_outputs = {
|
||||
"dets": TensorProto.FLOAT16,
|
||||
"labels": TensorProto.FLOAT16,
|
||||
}
|
||||
output_types = {item["name"]: item["element_type"] for item in outputs}
|
||||
if output_types != expected_outputs:
|
||||
raise RuntimeError(f"native FP16 ONNX outputs are not FLOAT16: {outputs}")
|
||||
initializer_counts = _initializer_type_counts(verified)
|
||||
if initializer_counts.get("FLOAT16", 0) == 0:
|
||||
raise RuntimeError("native FP16 ONNX has no FLOAT16 initializers")
|
||||
document = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"profile_id": PROFILE_ID,
|
||||
"source_onnx_sha256": source_sha256,
|
||||
"output_onnx_sha256": sha256_path(output),
|
||||
"output_size_bytes": output.stat().st_size,
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"initializer_type_counts": initializer_counts,
|
||||
"float_casts_retargeted_to_fp16": retargeted_casts,
|
||||
"started_utc_ns": started_utc_ns,
|
||||
"completed_utc_ns": time.time_ns(),
|
||||
"completed": True,
|
||||
"authority": FALSE_AUTHORITY,
|
||||
}
|
||||
manifest.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
manifest.write_bytes(canonical_json(document) + b"\n")
|
||||
print(output)
|
||||
print(json.dumps(document, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def _tensor_description(value: Any) -> dict[str, object]:
|
||||
tensor = value.type.tensor_type
|
||||
return {
|
||||
"name": value.name,
|
||||
"element_type": tensor.elem_type,
|
||||
"shape": [_dimension_value(item) for item in tensor.shape.dim],
|
||||
}
|
||||
|
||||
|
||||
def _dimension_value(value: Any) -> int | str | None:
|
||||
if value.HasField("dim_value"):
|
||||
return int(value.dim_value)
|
||||
if value.HasField("dim_param"):
|
||||
return str(value.dim_param)
|
||||
return None
|
||||
|
||||
|
||||
def _initializer_type_counts(graph: Any) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for initializer in graph.graph.initializer:
|
||||
name = TensorProto.DataType.Name(initializer.data_type)
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
return dict(sorted(counts.items()))
|
||||
|
||||
|
||||
def _insert_fp32_input_cast(graph: Any) -> None:
|
||||
input_value = next((item for item in graph.graph.input if item.name == "input"), None)
|
||||
if input_value is None:
|
||||
raise RuntimeError("native RF-DETR graph has no input tensor named 'input'")
|
||||
if input_value.type.tensor_type.elem_type != TensorProto.FLOAT16:
|
||||
raise RuntimeError("native RF-DETR input is not FLOAT16 before boundary adaptation")
|
||||
cast_output = "missioncore_input_fp16"
|
||||
for node in graph.graph.node:
|
||||
for index, name in enumerate(node.input):
|
||||
if name == "input":
|
||||
node.input[index] = cast_output
|
||||
cast = onnx.helper.make_node(
|
||||
"Cast",
|
||||
inputs=["input"],
|
||||
outputs=[cast_output],
|
||||
name="missioncore_input_fp32_to_fp16",
|
||||
to=TensorProto.FLOAT16,
|
||||
)
|
||||
graph.graph.node.insert(0, cast)
|
||||
input_value.type.tensor_type.elem_type = TensorProto.FLOAT
|
||||
|
||||
|
||||
def _retarget_float_casts_to_fp16(graph: Any) -> int:
|
||||
count = 0
|
||||
for node in graph.graph.node:
|
||||
if node.op_type != "Cast":
|
||||
continue
|
||||
for attribute in node.attribute:
|
||||
if attribute.name == "to" and attribute.i == TensorProto.FLOAT:
|
||||
attribute.i = TensorProto.FLOAT16
|
||||
count += 1
|
||||
if count == 0:
|
||||
raise RuntimeError("native RF-DETR graph has no FLOAT casts to retarget")
|
||||
return count
|
||||
|
||||
|
||||
def sha256_path(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 canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export RF-DETR-L at the native KB4 canvas without image resampling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import onnx # type: ignore[import-not-found]
|
||||
|
||||
SCHEMA_VERSION: Final = "missioncore.m48n-rf-detr-native-onnx-export/v0"
|
||||
PROFILE_ID: Final = "rf-detr-large-coco-native-kb4-608x800-fp16/v0"
|
||||
SOURCE_HEIGHT: Final = 600
|
||||
SOURCE_WIDTH: Final = 800
|
||||
MODEL_HEIGHT: Final = 608
|
||||
MODEL_WIDTH: Final = 800
|
||||
FALSE_AUTHORITY: Final = {
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--checkpoint", type=Path, required=True)
|
||||
parser.add_argument("--expected-checkpoint-sha256", required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--upstream-revision", required=True)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
checkpoint = arguments.checkpoint.resolve(strict=True)
|
||||
checkpoint_sha256 = sha256_path(checkpoint)
|
||||
if checkpoint_sha256 != arguments.expected_checkpoint_sha256:
|
||||
raise RuntimeError("checkpoint SHA-256 does not match the pinned finalist")
|
||||
output_root = arguments.output_root.absolute()
|
||||
manifest_path = arguments.manifest.absolute()
|
||||
if output_root.exists() or manifest_path.exists():
|
||||
raise RuntimeError("native ONNX output or manifest already exists")
|
||||
|
||||
from rfdetr import RFDETRLarge # type: ignore[import-not-found]
|
||||
|
||||
started_utc_ns = time.time_ns()
|
||||
model = RFDETRLarge(pretrain_weights=str(checkpoint))
|
||||
exported_path = Path(
|
||||
model.export(
|
||||
output_dir=str(output_root),
|
||||
format="onnx",
|
||||
shape=(MODEL_HEIGHT, MODEL_WIDTH),
|
||||
batch_size=1,
|
||||
dynamic_batch=False,
|
||||
opset_version=17,
|
||||
verbose=False,
|
||||
notes={
|
||||
"missioncore_profile_id": PROFILE_ID,
|
||||
"source_raster": [SOURCE_WIDTH, SOURCE_HEIGHT],
|
||||
"model_canvas": [MODEL_WIDTH, MODEL_HEIGHT],
|
||||
"geometric_resampling": False,
|
||||
"padding": {"top": 0, "bottom": 8, "left": 0, "right": 0},
|
||||
"upstream_revision": arguments.upstream_revision,
|
||||
"checkpoint_sha256": checkpoint_sha256,
|
||||
"authority": FALSE_AUTHORITY,
|
||||
},
|
||||
)
|
||||
).resolve(strict=True)
|
||||
graph = onnx.load(str(exported_path), load_external_data=False)
|
||||
onnx.checker.check_model(graph)
|
||||
inputs = [_tensor_description(value) for value in graph.graph.input]
|
||||
outputs = [_tensor_description(value) for value in graph.graph.output]
|
||||
expected_input = [
|
||||
{"name": "input", "element_type": 1, "shape": [1, 3, MODEL_HEIGHT, MODEL_WIDTH]}
|
||||
]
|
||||
if inputs != expected_input:
|
||||
raise RuntimeError(f"unexpected native RF-DETR ONNX input contract: {inputs}")
|
||||
if [item["name"] for item in outputs] != ["dets", "labels"]:
|
||||
raise RuntimeError(f"unexpected native RF-DETR ONNX outputs: {outputs}")
|
||||
|
||||
document = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"profile_id": PROFILE_ID,
|
||||
"provider_id": "shadow-rf-detr-large-coco-native-kb4-onnx/v0",
|
||||
"upstream_revision": arguments.upstream_revision,
|
||||
"checkpoint_sha256": checkpoint_sha256,
|
||||
"geometry": {
|
||||
"source_raster_wh": [SOURCE_WIDTH, SOURCE_HEIGHT],
|
||||
"model_canvas_wh": [MODEL_WIDTH, MODEL_HEIGHT],
|
||||
"padding_tblr": [0, 8, 0, 0],
|
||||
"resized": False,
|
||||
"rectified": False,
|
||||
"warped": False,
|
||||
},
|
||||
"onnx": {
|
||||
"path": str(exported_path),
|
||||
"sha256": sha256_path(exported_path),
|
||||
"size_bytes": exported_path.stat().st_size,
|
||||
"opset_imports": [
|
||||
{"domain": item.domain, "version": item.version} for item in graph.opset_import
|
||||
],
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
},
|
||||
"started_utc_ns": started_utc_ns,
|
||||
"completed_utc_ns": time.time_ns(),
|
||||
"completed": True,
|
||||
"authority": FALSE_AUTHORITY,
|
||||
}
|
||||
manifest_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
manifest_path.write_bytes(canonical_json(document) + b"\n")
|
||||
print(exported_path)
|
||||
print(json.dumps(document["onnx"], indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def _tensor_description(value: Any) -> dict[str, object]:
|
||||
tensor = value.type.tensor_type
|
||||
return {
|
||||
"name": value.name,
|
||||
"element_type": tensor.elem_type,
|
||||
"shape": [_dimension_value(item) for item in tensor.shape.dim],
|
||||
}
|
||||
|
||||
|
||||
def _dimension_value(value: Any) -> int | str | None:
|
||||
if value.HasField("dim_value"):
|
||||
return int(value.dim_value)
|
||||
if value.HasField("dim_param"):
|
||||
return str(value.dim_param)
|
||||
return None
|
||||
|
||||
|
||||
def sha256_path(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 canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare the native UINT8 TensorRT graph against the same RF-DETR PyTorch core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import av # type: ignore[import-not-found]
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from tritonclient import http as triton_http # type: ignore[import-not-found]
|
||||
|
||||
SCHEMA_VERSION: Final = "missioncore.m48n-native-pytorch-tensorrt-parity/v0"
|
||||
SOURCE_HEIGHT: Final = 600
|
||||
SOURCE_WIDTH: Final = 800
|
||||
MODEL_HEIGHT: Final = 608
|
||||
MODEL_WIDTH: Final = 800
|
||||
FILL_VALUE: Final = 114
|
||||
MEANS: Final = (0.485, 0.456, 0.406)
|
||||
STDS: Final = (0.229, 0.224, 0.225)
|
||||
DEFAULT_FRAME_INDICES: Final = (
|
||||
0,
|
||||
120,
|
||||
130,
|
||||
252,
|
||||
274,
|
||||
442,
|
||||
462,
|
||||
1093,
|
||||
1227,
|
||||
1453,
|
||||
1855,
|
||||
2385,
|
||||
2999,
|
||||
3999,
|
||||
4488,
|
||||
)
|
||||
FALSE_AUTHORITY: Final = {
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
RISK_SPARSE_CLASS_IDS: Final = frozenset(
|
||||
(1, 2, 3, 4, 6, 8, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 41)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodedDetection:
|
||||
sparse_class_id: int
|
||||
score: float
|
||||
box_xyxy: tuple[float, float, float, float]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", type=Path, required=True)
|
||||
parser.add_argument("--expected-video-sha256", required=True)
|
||||
parser.add_argument("--mask", type=Path, required=True)
|
||||
parser.add_argument("--expected-mask-sha256", required=True)
|
||||
parser.add_argument("--checkpoint", type=Path, required=True)
|
||||
parser.add_argument("--expected-checkpoint-sha256", required=True)
|
||||
parser.add_argument("--engine-sha256", required=True)
|
||||
parser.add_argument("--triton-origin", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--frame-indices", default=",".join(map(str, DEFAULT_FRAME_INDICES)))
|
||||
arguments = parser.parse_args()
|
||||
|
||||
for path, expected, label in (
|
||||
(arguments.video, arguments.expected_video_sha256, "video"),
|
||||
(arguments.mask, arguments.expected_mask_sha256, "valid-FOV mask"),
|
||||
(arguments.checkpoint, arguments.expected_checkpoint_sha256, "checkpoint"),
|
||||
):
|
||||
if sha256_path(path.resolve(strict=True)) != expected:
|
||||
raise RuntimeError(f"{label} SHA-256 changed")
|
||||
output = arguments.output.absolute()
|
||||
if output.exists():
|
||||
raise RuntimeError("native parity output already exists")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
frame_indices = _frame_indices(arguments.frame_indices)
|
||||
frames = load_frames(arguments.video, frame_indices)
|
||||
mask = np.asarray(Image.open(arguments.mask).convert("L")) > 0
|
||||
if mask.shape != (SOURCE_HEIGHT, SOURCE_WIDTH) or not np.any(mask):
|
||||
raise RuntimeError("valid-FOV mask geometry changed")
|
||||
|
||||
import torch # type: ignore[import-not-found]
|
||||
from rfdetr import RFDETRLarge # type: ignore[import-not-found]
|
||||
from rfdetr.models.backbone.dinov2 import DinoV2 # type: ignore[import-not-found]
|
||||
|
||||
model = RFDETRLarge(pretrain_weights=str(arguments.checkpoint))
|
||||
frozen_backbones = 0
|
||||
for module in model.model.model.modules():
|
||||
if isinstance(module, DinoV2):
|
||||
module.shape = (MODEL_HEIGHT, MODEL_WIDTH)
|
||||
module.export()
|
||||
frozen_backbones += 1
|
||||
if frozen_backbones == 0:
|
||||
raise RuntimeError("RF-DETR has no DINOv2 backbone to freeze for native shape")
|
||||
model.inference(compile=False, dtype=torch.float16, inplace=True)
|
||||
inference_model = model.model.inference_model
|
||||
if inference_model is None:
|
||||
raise RuntimeError("RF-DETR PyTorch inference model is unavailable")
|
||||
endpoint = urlsplit(arguments.triton_origin)
|
||||
if endpoint.scheme != "http" or not endpoint.hostname or endpoint.path not in ("", "/"):
|
||||
raise RuntimeError("Triton origin must be an HTTP origin")
|
||||
client = triton_http.InferenceServerClient(
|
||||
url=f"{endpoint.hostname}:{endpoint.port or 80}",
|
||||
verbose=False,
|
||||
)
|
||||
if not client.is_model_ready("rf_detr_large_native_kb4", "1"):
|
||||
raise RuntimeError("native Triton model is not ready")
|
||||
|
||||
frame_rows: list[dict[str, object]] = []
|
||||
triton_ms: list[float] = []
|
||||
preprocessing_ms: list[float] = []
|
||||
boxes_absolute_errors: list[float] = []
|
||||
logits_absolute_errors: list[float] = []
|
||||
top50_jaccards: list[float] = []
|
||||
reference_risk_count = 0
|
||||
candidate_risk_count = 0
|
||||
matched_risk_count = 0
|
||||
matched_risk_ious: list[float] = []
|
||||
matched_risk_score_errors: list[float] = []
|
||||
started_utc_ns = time.time_ns()
|
||||
try:
|
||||
for frame_index in frame_indices:
|
||||
image_bgr = frames[frame_index]
|
||||
preprocess_started = time.perf_counter_ns()
|
||||
reference_tensor = native_reference_preprocess(image_bgr, mask)
|
||||
preprocessing_ms.append((time.perf_counter_ns() - preprocess_started) / 1_000_000)
|
||||
torch_input = torch.from_numpy(reference_tensor).to("cuda", non_blocking=False)
|
||||
torch.cuda.synchronize()
|
||||
with torch.inference_mode():
|
||||
torch_output = inference_model(torch_input)
|
||||
torch.cuda.synchronize()
|
||||
pytorch_boxes, pytorch_logits = _pytorch_output(torch_output)
|
||||
|
||||
triton_input = triton_http.InferInput(
|
||||
"raw_kb4_bgr",
|
||||
[1, SOURCE_HEIGHT, SOURCE_WIDTH, 3],
|
||||
"UINT8",
|
||||
)
|
||||
triton_input.set_data_from_numpy(image_bgr[None], binary_data=True)
|
||||
requested = [
|
||||
triton_http.InferRequestedOutput("dets", binary_data=True),
|
||||
triton_http.InferRequestedOutput("labels", binary_data=True),
|
||||
]
|
||||
inference_started = time.perf_counter_ns()
|
||||
response = client.infer(
|
||||
"rf_detr_large_native_kb4",
|
||||
[triton_input],
|
||||
model_version="1",
|
||||
outputs=requested,
|
||||
)
|
||||
elapsed_ms = (time.perf_counter_ns() - inference_started) / 1_000_000
|
||||
triton_ms.append(elapsed_ms)
|
||||
tensorrt_boxes = _array(response.as_numpy("dets"), (1, 300, 4), "dets")
|
||||
tensorrt_logits = _array(response.as_numpy("labels"), (1, 300, 91), "labels")
|
||||
|
||||
box_errors = np.abs(
|
||||
pytorch_boxes.astype(np.float32) - tensorrt_boxes.astype(np.float32)
|
||||
)
|
||||
logit_errors = np.abs(
|
||||
pytorch_logits.astype(np.float32) - tensorrt_logits.astype(np.float32)
|
||||
)
|
||||
boxes_absolute_errors.extend(float(value) for value in box_errors.reshape(-1))
|
||||
logits_absolute_errors.extend(float(value) for value in logit_errors.reshape(-1))
|
||||
pytorch_top50 = set(
|
||||
int(value)
|
||||
for value in np.argsort(-pytorch_logits[0].astype(np.float32).reshape(-1))[:50]
|
||||
)
|
||||
tensorrt_top50 = set(
|
||||
int(value)
|
||||
for value in np.argsort(-tensorrt_logits[0].astype(np.float32).reshape(-1))[:50]
|
||||
)
|
||||
top50_jaccard = len(pytorch_top50 & tensorrt_top50) / len(
|
||||
pytorch_top50 | tensorrt_top50
|
||||
)
|
||||
top50_jaccards.append(top50_jaccard)
|
||||
reference_risk = decode_risk_detections(pytorch_boxes, pytorch_logits)
|
||||
candidate_risk = decode_risk_detections(tensorrt_boxes, tensorrt_logits)
|
||||
matched = match_detections(reference_risk, candidate_risk)
|
||||
reference_risk_count += len(reference_risk)
|
||||
candidate_risk_count += len(candidate_risk)
|
||||
matched_risk_count += len(matched)
|
||||
matched_risk_ious.extend(item[0] for item in matched)
|
||||
matched_risk_score_errors.extend(item[1] for item in matched)
|
||||
frame_rows.append(
|
||||
{
|
||||
"frame_index": frame_index,
|
||||
"triton_ms": round(elapsed_ms, 6),
|
||||
"boxes_max_abs": round(float(box_errors.max()), 9),
|
||||
"boxes_p99_abs": round(_percentile_array(box_errors, 0.99), 9),
|
||||
"logits_max_abs": round(float(logit_errors.max()), 9),
|
||||
"logits_p99_abs": round(_percentile_array(logit_errors, 0.99), 9),
|
||||
"top50_query_class_jaccard": round(top50_jaccard, 9),
|
||||
"reference_risk_detections": len(reference_risk),
|
||||
"candidate_risk_detections": len(candidate_risk),
|
||||
"matched_risk_detections_iou_at_least_0_5": len(matched),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
boxes_report = _error_report(boxes_absolute_errors)
|
||||
logits_report = _error_report(logits_absolute_errors)
|
||||
timing = _timing_report(triton_ms)
|
||||
preprocessing_reference = _timing_report(preprocessing_ms)
|
||||
risk_recall = matched_risk_count / reference_risk_count if reference_risk_count else 1.0
|
||||
risk_precision = matched_risk_count / candidate_risk_count if candidate_risk_count else 1.0
|
||||
risk_mean_iou = statistics.fmean(matched_risk_ious) if matched_risk_ious else 1.0
|
||||
risk_score_p95 = (
|
||||
_percentile(sorted(matched_risk_score_errors), 0.95)
|
||||
if matched_risk_score_errors
|
||||
else 0.0
|
||||
)
|
||||
gates = {
|
||||
"risk_detection_recall_at_least_0_95": risk_recall >= 0.95,
|
||||
"risk_detection_precision_at_least_0_95": risk_precision >= 0.95,
|
||||
"matched_risk_mean_iou_at_least_0_90": risk_mean_iou >= 0.90,
|
||||
"matched_risk_score_error_p95_at_most_0_10": risk_score_p95 <= 0.10,
|
||||
"all_outputs_finite": all(
|
||||
math.isfinite(value) for value in boxes_absolute_errors + logits_absolute_errors
|
||||
),
|
||||
}
|
||||
report: dict[str, object] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"completed": True,
|
||||
"passed": all(gates.values()),
|
||||
"source": {
|
||||
"video_sha256": arguments.expected_video_sha256,
|
||||
"valid_fov_mask_sha256": arguments.expected_mask_sha256,
|
||||
"frame_indices": list(frame_indices),
|
||||
"raw_raster_wh": [SOURCE_WIDTH, SOURCE_HEIGHT],
|
||||
},
|
||||
"candidate": {
|
||||
"checkpoint_sha256": arguments.expected_checkpoint_sha256,
|
||||
"engine_sha256": arguments.engine_sha256,
|
||||
"input": {
|
||||
"name": "raw_kb4_bgr",
|
||||
"datatype": "UINT8",
|
||||
"shape": [1, SOURCE_HEIGHT, SOURCE_WIDTH, 3],
|
||||
"bytes_per_frame": SOURCE_HEIGHT * SOURCE_WIDTH * 3,
|
||||
},
|
||||
"model_canvas_wh": [MODEL_WIDTH, MODEL_HEIGHT],
|
||||
"pytorch_reference_frozen_dinov2_backbones": frozen_backbones,
|
||||
"geometric_resampling": False,
|
||||
"padding_tblr": [0, 8, 0, 0],
|
||||
},
|
||||
"numeric_parity": {
|
||||
"raw_query_diagnostics_not_acceptance_gates": {
|
||||
"reason": "DETR top-k query identity is not stable across equivalent FP16 runtimes",
|
||||
"boxes_absolute_error_by_query_index": boxes_report,
|
||||
"logits_absolute_error_by_query_index": logits_report,
|
||||
"mean_top50_query_class_jaccard": round(statistics.fmean(top50_jaccards), 9),
|
||||
},
|
||||
"risk_semantic_output_parity": {
|
||||
"minimum_score": 0.25,
|
||||
"minimum_match_iou": 0.5,
|
||||
"reference_detection_count": reference_risk_count,
|
||||
"candidate_detection_count": candidate_risk_count,
|
||||
"matched_detection_count": matched_risk_count,
|
||||
"recall": round(risk_recall, 9),
|
||||
"precision": round(risk_precision, 9),
|
||||
"matched_mean_iou": round(risk_mean_iou, 9),
|
||||
"matched_score_absolute_error_p95": round(risk_score_p95, 9),
|
||||
},
|
||||
},
|
||||
"timing": {
|
||||
"native_triton_raw_transport_and_inference_ms": timing,
|
||||
"cpu_reference_preprocess_ms_not_in_candidate_path": preprocessing_reference,
|
||||
},
|
||||
"frames": frame_rows,
|
||||
"gates": gates,
|
||||
"execution": {
|
||||
"started_utc_ns": started_utc_ns,
|
||||
"completed_utc_ns": time.time_ns(),
|
||||
"packages": _package_versions(
|
||||
("rfdetr", "torch", "numpy", "av", "tritonclient")
|
||||
),
|
||||
},
|
||||
"authority": FALSE_AUTHORITY,
|
||||
}
|
||||
report["report_identity_sha256"] = hashlib.sha256(canonical_json(report)).hexdigest()
|
||||
output.write_bytes(canonical_json(report) + b"\n")
|
||||
print(json.dumps({"output": str(output), "passed": report["passed"], "timing": timing}))
|
||||
return 0
|
||||
|
||||
|
||||
def native_reference_preprocess(
|
||||
image_bgr: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
mask: np.ndarray[Any, np.dtype[np.bool_]],
|
||||
) -> np.ndarray[Any, np.dtype[np.float16]]:
|
||||
if image_bgr.shape != (SOURCE_HEIGHT, SOURCE_WIDTH, 3) or image_bgr.dtype != np.uint8:
|
||||
raise RuntimeError("raw KB4 frame contract changed")
|
||||
raw = image_bgr.astype(np.float16)
|
||||
valid = mask.astype(np.float16)[..., None]
|
||||
invalid_fill = (~mask).astype(np.float16)[..., None] * np.float16(FILL_VALUE)
|
||||
masked = raw * valid + invalid_fill
|
||||
padded = np.full((MODEL_HEIGHT, MODEL_WIDTH, 3), FILL_VALUE, dtype=np.float16)
|
||||
padded[:SOURCE_HEIGHT] = masked
|
||||
nchw = np.ascontiguousarray(padded[:, :, ::-1].transpose(2, 0, 1))[None]
|
||||
scale = np.asarray([1.0 / (255.0 * value) for value in STDS], dtype=np.float16)
|
||||
bias = np.asarray(
|
||||
[-mean / std for mean, std in zip(MEANS, STDS, strict=True)],
|
||||
dtype=np.float16,
|
||||
)
|
||||
return np.ascontiguousarray(
|
||||
nchw * scale.reshape(1, 3, 1, 1) + bias.reshape(1, 3, 1, 1),
|
||||
dtype=np.float16,
|
||||
)
|
||||
|
||||
|
||||
def decode_risk_detections(
|
||||
boxes: np.ndarray[Any, Any],
|
||||
logits: np.ndarray[Any, Any],
|
||||
) -> tuple[DecodedDetection, ...]:
|
||||
probabilities = 1.0 / (1.0 + np.exp(-np.clip(logits[0].astype(np.float32), -80, 80)))
|
||||
flattened = probabilities.reshape(-1)
|
||||
topk = np.argsort(-flattened, kind="stable")[:300]
|
||||
result: list[DecodedDetection] = []
|
||||
for flat_index in topk:
|
||||
score = float(flattened[flat_index])
|
||||
if score <= 0.25:
|
||||
continue
|
||||
sparse_class_id = int(flat_index % logits.shape[2])
|
||||
if sparse_class_id not in RISK_SPARSE_CLASS_IDS:
|
||||
continue
|
||||
query_index = int(flat_index // logits.shape[2])
|
||||
center_x, center_y, width, height = (
|
||||
float(value) for value in boxes[0, query_index].astype(np.float32)
|
||||
)
|
||||
box = (
|
||||
min(max((center_x - width / 2) * MODEL_WIDTH, 0.0), float(SOURCE_WIDTH)),
|
||||
min(max((center_y - height / 2) * MODEL_HEIGHT, 0.0), float(SOURCE_HEIGHT)),
|
||||
min(max((center_x + width / 2) * MODEL_WIDTH, 0.0), float(SOURCE_WIDTH)),
|
||||
min(max((center_y + height / 2) * MODEL_HEIGHT, 0.0), float(SOURCE_HEIGHT)),
|
||||
)
|
||||
if box[2] <= box[0] or box[3] <= box[1]:
|
||||
continue
|
||||
result.append(DecodedDetection(sparse_class_id, score, box))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def match_detections(
|
||||
reference: tuple[DecodedDetection, ...],
|
||||
candidate: tuple[DecodedDetection, ...],
|
||||
) -> tuple[tuple[float, float], ...]:
|
||||
unused = set(range(len(candidate)))
|
||||
matches: list[tuple[float, float]] = []
|
||||
for expected in sorted(reference, key=lambda item: -item.score):
|
||||
ranked = sorted(
|
||||
(
|
||||
(_box_iou(expected.box_xyxy, candidate[index].box_xyxy), index)
|
||||
for index in unused
|
||||
if candidate[index].sparse_class_id == expected.sparse_class_id
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
if not ranked or ranked[0][0] < 0.5:
|
||||
continue
|
||||
iou, index = ranked[0]
|
||||
unused.remove(index)
|
||||
matches.append((iou, abs(expected.score - candidate[index].score)))
|
||||
return tuple(matches)
|
||||
|
||||
|
||||
def _box_iou(
|
||||
left: tuple[float, float, float, float],
|
||||
right: tuple[float, float, float, float],
|
||||
) -> float:
|
||||
intersection_width = max(0.0, min(left[2], right[2]) - max(left[0], right[0]))
|
||||
intersection_height = max(0.0, min(left[3], right[3]) - max(left[1], right[1]))
|
||||
intersection = intersection_width * intersection_height
|
||||
left_area = (left[2] - left[0]) * (left[3] - left[1])
|
||||
right_area = (right[2] - right[0]) * (right[3] - right[1])
|
||||
union = left_area + right_area - intersection
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def load_frames(
|
||||
video_path: Path,
|
||||
indices: tuple[int, ...],
|
||||
) -> dict[int, np.ndarray[Any, np.dtype[np.uint8]]]:
|
||||
wanted = set(indices)
|
||||
frames: dict[int, np.ndarray[Any, np.dtype[np.uint8]]] = {}
|
||||
with av.open(str(video_path)) as container:
|
||||
for index, frame in enumerate(container.decode(video=0)):
|
||||
if index in wanted:
|
||||
image = np.ascontiguousarray(frame.to_ndarray(format="bgr24"), dtype=np.uint8)
|
||||
if image.shape != (SOURCE_HEIGHT, SOURCE_WIDTH, 3):
|
||||
raise RuntimeError("decoded KB4 raster changed")
|
||||
frames[index] = image
|
||||
if len(frames) == len(indices):
|
||||
break
|
||||
missing = wanted - set(frames)
|
||||
if missing:
|
||||
raise RuntimeError(f"selected video frames are missing: {sorted(missing)}")
|
||||
return frames
|
||||
|
||||
|
||||
def _pytorch_output(value: object) -> tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]:
|
||||
if isinstance(value, Mapping):
|
||||
boxes_value = value.get("pred_boxes")
|
||||
logits_value = value.get("pred_logits")
|
||||
elif isinstance(value, (tuple, list)) and len(value) == 2:
|
||||
boxes_value, logits_value = value
|
||||
else:
|
||||
raise RuntimeError("PyTorch RF-DETR output contract changed")
|
||||
for item in (boxes_value, logits_value):
|
||||
if not hasattr(item, "detach"):
|
||||
raise RuntimeError("PyTorch RF-DETR output is not a tensor")
|
||||
boxes = boxes_value.detach().to("cpu").numpy() # type: ignore[union-attr]
|
||||
logits = logits_value.detach().to("cpu").numpy() # type: ignore[union-attr]
|
||||
return _array(boxes, (1, 300, 4), "PyTorch boxes"), _array(
|
||||
logits, (1, 300, 91), "PyTorch logits"
|
||||
)
|
||||
|
||||
|
||||
def _array(value: object, shape: tuple[int, ...], label: str) -> np.ndarray[Any, Any]:
|
||||
if not isinstance(value, np.ndarray) or value.shape != shape or value.dtype != np.float16:
|
||||
raise RuntimeError(f"{label} tensor contract changed")
|
||||
if not np.isfinite(value).all():
|
||||
raise RuntimeError(f"{label} contains non-finite values")
|
||||
return value
|
||||
|
||||
|
||||
def _frame_indices(raw: str) -> tuple[int, ...]:
|
||||
try:
|
||||
result = tuple(sorted({int(value) for value in raw.split(",")}))
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("frame indices are invalid") from exc
|
||||
if not result or result[0] < 0:
|
||||
raise RuntimeError("frame indices must be nonnegative")
|
||||
return result
|
||||
|
||||
|
||||
def _error_report(values: list[float]) -> dict[str, float]:
|
||||
ordered = sorted(values)
|
||||
return {
|
||||
"mean": round(statistics.fmean(ordered), 9),
|
||||
"p50": round(_percentile(ordered, 0.5), 9),
|
||||
"p95": round(_percentile(ordered, 0.95), 9),
|
||||
"p99": round(_percentile(ordered, 0.99), 9),
|
||||
"maximum": round(ordered[-1], 9),
|
||||
}
|
||||
|
||||
|
||||
def _timing_report(values: list[float]) -> dict[str, float]:
|
||||
ordered = sorted(values)
|
||||
return {
|
||||
"mean": round(statistics.fmean(ordered), 6),
|
||||
"p50": round(_percentile(ordered, 0.5), 6),
|
||||
"p95": round(_percentile(ordered, 0.95), 6),
|
||||
"p99": round(_percentile(ordered, 0.99), 6),
|
||||
"maximum": round(ordered[-1], 6),
|
||||
}
|
||||
|
||||
|
||||
def _percentile_array(values: np.ndarray[Any, Any], quantile: float) -> float:
|
||||
return float(np.quantile(values.astype(np.float64), quantile))
|
||||
|
||||
|
||||
def _percentile(values: list[float], quantile: float) -> float:
|
||||
position = (len(values) - 1) * quantile
|
||||
lower = math.floor(position)
|
||||
upper = math.ceil(position)
|
||||
if lower == upper:
|
||||
return values[lower]
|
||||
return values[lower] + (values[upper] - values[lower]) * (position - lower)
|
||||
|
||||
|
||||
def _package_versions(names: tuple[str, ...]) -> dict[str, str]:
|
||||
return {name: importlib.metadata.version(name) for name in names}
|
||||
|
||||
|
||||
def sha256_path(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 canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,588 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare native raw-KB4 RF-DETR against the frozen 704 pipeline on RAVNOVES00."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import av # type: ignore[import-not-found]
|
||||
import cv2 # type: ignore[import-not-found]
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from tritonclient import http as triton_http # type: ignore[import-not-found]
|
||||
|
||||
SCHEMA_VERSION: Final = "missioncore.m48n-native-vs-704-ravnoves00/v0"
|
||||
SOURCE_HEIGHT: Final = 600
|
||||
SOURCE_WIDTH: Final = 800
|
||||
NATIVE_MODEL_HEIGHT: Final = 608
|
||||
NATIVE_MODEL_WIDTH: Final = 800
|
||||
FILL_VALUE: Final = 114
|
||||
MEANS: Final = np.asarray((0.485, 0.456, 0.406), dtype=np.float32)
|
||||
STDS: Final = np.asarray((0.229, 0.224, 0.225), dtype=np.float32)
|
||||
RISK_LABEL_BY_SPARSE_ID: Final = {
|
||||
1: "person",
|
||||
2: "bicycle",
|
||||
3: "car",
|
||||
4: "motorcycle",
|
||||
6: "bus",
|
||||
8: "truck",
|
||||
16: "bird",
|
||||
17: "cat",
|
||||
18: "dog",
|
||||
19: "horse",
|
||||
20: "sheep",
|
||||
21: "cow",
|
||||
22: "elephant",
|
||||
23: "bear",
|
||||
24: "zebra",
|
||||
25: "giraffe",
|
||||
41: "skateboard",
|
||||
}
|
||||
FALSE_AUTHORITY: Final = {
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Detection:
|
||||
label: str
|
||||
score: float
|
||||
box_xyxy: tuple[float, float, float, float]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--video", type=Path, required=True)
|
||||
parser.add_argument("--expected-video-sha256", required=True)
|
||||
parser.add_argument("--mask", type=Path, required=True)
|
||||
parser.add_argument("--expected-mask-sha256", required=True)
|
||||
parser.add_argument("--baseline-triton-origin", required=True)
|
||||
parser.add_argument("--native-triton-origin", required=True)
|
||||
parser.add_argument("--baseline-engine-sha256", required=True)
|
||||
parser.add_argument("--native-engine-sha256", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--frames", type=Path, required=True)
|
||||
parser.add_argument("--maximum-frames", type=int, default=0)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
if arguments.maximum_frames < 0:
|
||||
raise RuntimeError("maximum frames cannot be negative")
|
||||
for path, expected, label in (
|
||||
(arguments.video, arguments.expected_video_sha256, "video"),
|
||||
(arguments.mask, arguments.expected_mask_sha256, "valid-FOV mask"),
|
||||
):
|
||||
if sha256_path(path.resolve(strict=True)) != expected:
|
||||
raise RuntimeError(f"{label} SHA-256 changed")
|
||||
for target in (arguments.output, arguments.frames):
|
||||
if target.exists():
|
||||
raise RuntimeError(f"output already exists: {target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mask = np.asarray(Image.open(arguments.mask).convert("L")) > 0
|
||||
if mask.shape != (SOURCE_HEIGHT, SOURCE_WIDTH) or not np.any(mask):
|
||||
raise RuntimeError("valid-FOV mask geometry changed")
|
||||
baseline_client = _client(arguments.baseline_triton_origin)
|
||||
native_client = _client(arguments.native_triton_origin)
|
||||
if not baseline_client.is_model_ready("rf_detr_large", "1"):
|
||||
raise RuntimeError("frozen 704 Triton model is not ready")
|
||||
if not native_client.is_model_ready("rf_detr_large_native_kb4", "1"):
|
||||
raise RuntimeError("native Triton model is not ready")
|
||||
|
||||
baseline_timing: dict[str, list[float]] = {
|
||||
"preprocess": [],
|
||||
"inference_transport": [],
|
||||
"postprocess": [],
|
||||
"total": [],
|
||||
}
|
||||
native_timing: dict[str, list[float]] = {
|
||||
"client_prepare": [],
|
||||
"inference_transport": [],
|
||||
"postprocess": [],
|
||||
"total": [],
|
||||
}
|
||||
baseline_rejected: Counter[str] = Counter()
|
||||
native_rejected: Counter[str] = Counter()
|
||||
baseline_labels: Counter[str] = Counter()
|
||||
native_labels: Counter[str] = Counter()
|
||||
baseline_detection_count = 0
|
||||
native_detection_count = 0
|
||||
matched_detection_count = 0
|
||||
matched_ious: list[float] = []
|
||||
matched_score_errors: list[float] = []
|
||||
started_utc_ns = time.time_ns()
|
||||
frame_count = 0
|
||||
|
||||
zero = np.zeros((SOURCE_HEIGHT, SOURCE_WIDTH, 3), dtype=np.uint8)
|
||||
_infer_baseline(baseline_client, preprocess_704(zero, mask))
|
||||
_infer_native(native_client, zero)
|
||||
try:
|
||||
with av.open(str(arguments.video)) as container, arguments.frames.open(
|
||||
"x", encoding="utf-8"
|
||||
) as frame_stream:
|
||||
for frame_index, frame in enumerate(container.decode(video=0)):
|
||||
if arguments.maximum_frames and frame_index >= arguments.maximum_frames:
|
||||
break
|
||||
image_bgr = np.ascontiguousarray(frame.to_ndarray(format="bgr24"), dtype=np.uint8)
|
||||
if image_bgr.shape != (SOURCE_HEIGHT, SOURCE_WIDTH, 3):
|
||||
raise RuntimeError("decoded KB4 raster changed")
|
||||
|
||||
baseline_started = time.perf_counter_ns()
|
||||
baseline_preprocess_started = baseline_started
|
||||
baseline_tensor = preprocess_704(image_bgr, mask)
|
||||
baseline_inference_started = time.perf_counter_ns()
|
||||
baseline_output = _infer_baseline(baseline_client, baseline_tensor)
|
||||
baseline_postprocess_started = time.perf_counter_ns()
|
||||
baseline_detections, rejected = postprocess(
|
||||
*baseline_output,
|
||||
mask=mask,
|
||||
canvas_width=SOURCE_WIDTH,
|
||||
canvas_height=SOURCE_HEIGHT,
|
||||
)
|
||||
baseline_completed = time.perf_counter_ns()
|
||||
baseline_rejected.update(rejected)
|
||||
_append_timing(
|
||||
baseline_timing,
|
||||
baseline_preprocess_started,
|
||||
baseline_inference_started,
|
||||
baseline_postprocess_started,
|
||||
baseline_completed,
|
||||
first_key="preprocess",
|
||||
)
|
||||
|
||||
native_started = time.perf_counter_ns()
|
||||
native_input = np.ascontiguousarray(image_bgr, dtype=np.uint8)
|
||||
native_inference_started = time.perf_counter_ns()
|
||||
native_output = _infer_native(native_client, native_input)
|
||||
native_postprocess_started = time.perf_counter_ns()
|
||||
native_detections, rejected = postprocess(
|
||||
*native_output,
|
||||
mask=mask,
|
||||
canvas_width=NATIVE_MODEL_WIDTH,
|
||||
canvas_height=NATIVE_MODEL_HEIGHT,
|
||||
)
|
||||
native_completed = time.perf_counter_ns()
|
||||
native_rejected.update(rejected)
|
||||
_append_timing(
|
||||
native_timing,
|
||||
native_started,
|
||||
native_inference_started,
|
||||
native_postprocess_started,
|
||||
native_completed,
|
||||
first_key="client_prepare",
|
||||
)
|
||||
|
||||
matches = match_detections(baseline_detections, native_detections)
|
||||
baseline_detection_count += len(baseline_detections)
|
||||
native_detection_count += len(native_detections)
|
||||
matched_detection_count += len(matches)
|
||||
matched_ious.extend(item[0] for item in matches)
|
||||
matched_score_errors.extend(item[1] for item in matches)
|
||||
baseline_labels.update(item.label for item in baseline_detections)
|
||||
native_labels.update(item.label for item in native_detections)
|
||||
frame_stream.write(
|
||||
canonical_json_text(
|
||||
{
|
||||
"frame_index": frame_index,
|
||||
"baseline_detection_count": len(baseline_detections),
|
||||
"native_detection_count": len(native_detections),
|
||||
"matched_detection_count_iou_at_least_0_5": len(matches),
|
||||
"baseline_total_ms": round(
|
||||
(baseline_completed - baseline_started) / 1_000_000, 6
|
||||
),
|
||||
"native_total_ms": round(
|
||||
(native_completed - native_started) / 1_000_000, 6
|
||||
),
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
frame_count += 1
|
||||
if frame_count % 100 == 0:
|
||||
frame_stream.flush()
|
||||
print(
|
||||
canonical_json_text(
|
||||
{
|
||||
"frame_count": frame_count,
|
||||
"baseline_total_ms_latest": round(
|
||||
baseline_timing["total"][-1], 6
|
||||
),
|
||||
"native_total_ms_latest": round(
|
||||
native_timing["total"][-1], 6
|
||||
),
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
baseline_client.close()
|
||||
native_client.close()
|
||||
|
||||
if frame_count == 0:
|
||||
raise RuntimeError("RAVNOVES00 selection is empty")
|
||||
baseline_recall = (
|
||||
matched_detection_count / baseline_detection_count if baseline_detection_count else 1.0
|
||||
)
|
||||
agreement_precision = (
|
||||
matched_detection_count / native_detection_count if native_detection_count else 1.0
|
||||
)
|
||||
baseline_total = _distribution(baseline_timing["total"])
|
||||
native_total = _distribution(native_timing["total"])
|
||||
checks = {
|
||||
"native_total_p95_below_baseline": native_total["p95"] < baseline_total["p95"],
|
||||
"native_total_mean_below_baseline": native_total["mean"] < baseline_total["mean"],
|
||||
"native_total_p95_at_most_25_ms": native_total["p95"] <= 25.0,
|
||||
"baseline_detection_retention_recall_at_least_0_80": baseline_recall >= 0.80,
|
||||
"authority_remains_false": True,
|
||||
}
|
||||
report: dict[str, object] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"completed": True,
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"video_sha256": arguments.expected_video_sha256,
|
||||
"valid_fov_mask_sha256": arguments.expected_mask_sha256,
|
||||
"frame_count": frame_count,
|
||||
"full_video": arguments.maximum_frames == 0,
|
||||
},
|
||||
"providers": {
|
||||
"baseline_704": {
|
||||
"engine_sha256": arguments.baseline_engine_sha256,
|
||||
"preprocessing": "CPU mask+BGR-to-RGB+bilinear-704x704+ImageNet-normalize",
|
||||
"input_bytes_per_frame": 1 * 3 * 704 * 704 * 4,
|
||||
"detection_count": baseline_detection_count,
|
||||
"label_counts": dict(sorted(baseline_labels.items())),
|
||||
"rejected": dict(sorted(baseline_rejected.items())),
|
||||
"timing_ms": {key: _distribution(value) for key, value in baseline_timing.items()},
|
||||
},
|
||||
"native_608x800": {
|
||||
"engine_sha256": arguments.native_engine_sha256,
|
||||
"preprocessing": "single TensorRT GPU graph; no geometric resampling",
|
||||
"input_bytes_per_frame": SOURCE_HEIGHT * SOURCE_WIDTH * 3,
|
||||
"detection_count": native_detection_count,
|
||||
"label_counts": dict(sorted(native_labels.items())),
|
||||
"rejected": dict(sorted(native_rejected.items())),
|
||||
"timing_ms": {key: _distribution(value) for key, value in native_timing.items()},
|
||||
},
|
||||
},
|
||||
"comparison": {
|
||||
"matched_detection_count_iou_at_least_0_5": matched_detection_count,
|
||||
"baseline_detection_retention_recall": round(baseline_recall, 9),
|
||||
"native_agreement_precision": round(agreement_precision, 9),
|
||||
"matched_mean_iou": round(statistics.fmean(matched_ious), 9)
|
||||
if matched_ious
|
||||
else 1.0,
|
||||
"matched_score_absolute_error_p95": round(
|
||||
_percentile(sorted(matched_score_errors), 0.95), 9
|
||||
)
|
||||
if matched_score_errors
|
||||
else 0.0,
|
||||
"native_total_p95_delta_ms": round(native_total["p95"] - baseline_total["p95"], 6),
|
||||
"native_total_mean_delta_ms": round(native_total["mean"] - baseline_total["mean"], 6),
|
||||
"transport_bytes_reduction_fraction": round(
|
||||
1.0 - (SOURCE_HEIGHT * SOURCE_WIDTH * 3) / (1 * 3 * 704 * 704 * 4),
|
||||
9,
|
||||
),
|
||||
},
|
||||
"checks": checks,
|
||||
"execution": {
|
||||
"started_utc_ns": started_utc_ns,
|
||||
"completed_utc_ns": time.time_ns(),
|
||||
"frames_sha256": sha256_path(arguments.frames),
|
||||
},
|
||||
"authority": FALSE_AUTHORITY,
|
||||
}
|
||||
report["passed"] = all(checks.values())
|
||||
report["report_identity_sha256"] = hashlib.sha256(canonical_json(report)).hexdigest()
|
||||
arguments.output.write_bytes(canonical_json(report) + b"\n")
|
||||
print(
|
||||
canonical_json_text(
|
||||
{
|
||||
"output": str(arguments.output),
|
||||
"passed": report["passed"],
|
||||
"frame_count": frame_count,
|
||||
"baseline_total_ms": baseline_total,
|
||||
"native_total_ms": native_total,
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def preprocess_704(
|
||||
image_bgr: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
mask: np.ndarray[Any, np.dtype[np.bool_]],
|
||||
) -> np.ndarray[Any, np.dtype[np.float32]]:
|
||||
masked_bgr = np.where(mask[..., None], image_bgr, FILL_VALUE).astype(np.uint8)
|
||||
rgb = np.ascontiguousarray(masked_bgr[:, :, ::-1])
|
||||
resized = cv2.resize(rgb, (704, 704), interpolation=cv2.INTER_LINEAR)
|
||||
normalized = resized.astype(np.float32) / 255.0
|
||||
normalized = (normalized - MEANS) / STDS
|
||||
return np.ascontiguousarray(normalized.transpose(2, 0, 1), dtype=np.float32)[None]
|
||||
|
||||
|
||||
def postprocess(
|
||||
boxes: np.ndarray[Any, Any],
|
||||
logits: np.ndarray[Any, Any],
|
||||
*,
|
||||
mask: np.ndarray[Any, np.dtype[np.bool_]],
|
||||
canvas_width: int,
|
||||
canvas_height: int,
|
||||
) -> tuple[tuple[Detection, ...], Counter[str]]:
|
||||
if boxes.shape != (1, 300, 4) or boxes.dtype != np.float16:
|
||||
raise RuntimeError("RF-DETR box tensor changed")
|
||||
if logits.shape != (1, 300, 91) or logits.dtype != np.float16:
|
||||
raise RuntimeError("RF-DETR logits tensor changed")
|
||||
if not np.isfinite(boxes).all() or not np.isfinite(logits).all():
|
||||
raise RuntimeError("RF-DETR output contains non-finite values")
|
||||
probabilities = 1.0 / (1.0 + np.exp(-np.clip(logits[0].astype(np.float32), -80, 80)))
|
||||
flattened = probabilities.reshape(-1)
|
||||
topk = np.argsort(-flattened, kind="stable")[:300]
|
||||
integral = np.pad(mask.astype(np.int64), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
|
||||
result: list[Detection] = []
|
||||
rejected: Counter[str] = Counter()
|
||||
for flat_index in topk:
|
||||
score = float(flattened[flat_index])
|
||||
if score <= 0.25:
|
||||
continue
|
||||
sparse_class_id = int(flat_index % logits.shape[2])
|
||||
label = RISK_LABEL_BY_SPARSE_ID.get(sparse_class_id)
|
||||
if label is None:
|
||||
rejected["non-risk-or-unmapped-class"] += 1
|
||||
continue
|
||||
query_index = int(flat_index // logits.shape[2])
|
||||
center_x, center_y, width, height = (
|
||||
float(value) for value in boxes[0, query_index].astype(np.float32)
|
||||
)
|
||||
box = np.asarray(
|
||||
(
|
||||
(center_x - width / 2) * canvas_width,
|
||||
(center_y - height / 2) * canvas_height,
|
||||
(center_x + width / 2) * canvas_width,
|
||||
(center_y + height / 2) * canvas_height,
|
||||
),
|
||||
dtype=np.float32,
|
||||
)
|
||||
box[[0, 2]] = np.clip(box[[0, 2]], 0, SOURCE_WIDTH)
|
||||
box[[1, 3]] = np.clip(box[[1, 3]], 0, SOURCE_HEIGHT)
|
||||
fraction, center_inside, area = _valid_fraction(box, integral)
|
||||
if area < 64.0:
|
||||
rejected["small-box"] += 1
|
||||
continue
|
||||
if area / (SOURCE_WIDTH * SOURCE_HEIGHT) > 0.5:
|
||||
rejected["large-box"] += 1
|
||||
continue
|
||||
if fraction < 0.5:
|
||||
rejected["outside-valid-fov"] += 1
|
||||
continue
|
||||
if not center_inside:
|
||||
rejected["center-outside-valid-fov"] += 1
|
||||
continue
|
||||
result.append(
|
||||
Detection(
|
||||
label,
|
||||
score,
|
||||
tuple(float(value) for value in box), # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
return tuple(sorted(result, key=lambda item: (-item.score, item.label))), rejected
|
||||
|
||||
|
||||
def match_detections(
|
||||
baseline: tuple[Detection, ...],
|
||||
native: tuple[Detection, ...],
|
||||
) -> tuple[tuple[float, float], ...]:
|
||||
unused = set(range(len(native)))
|
||||
matches: list[tuple[float, float]] = []
|
||||
for expected in baseline:
|
||||
ranked = sorted(
|
||||
(
|
||||
(_box_iou(expected.box_xyxy, native[index].box_xyxy), index)
|
||||
for index in unused
|
||||
if native[index].label == expected.label
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
if not ranked or ranked[0][0] < 0.5:
|
||||
continue
|
||||
iou, index = ranked[0]
|
||||
unused.remove(index)
|
||||
matches.append((iou, abs(expected.score - native[index].score)))
|
||||
return tuple(matches)
|
||||
|
||||
|
||||
def _infer_baseline(
|
||||
client: triton_http.InferenceServerClient,
|
||||
tensor: np.ndarray[Any, np.dtype[np.float32]],
|
||||
) -> tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]:
|
||||
return _infer(
|
||||
client,
|
||||
model_name="rf_detr_large",
|
||||
input_name="input",
|
||||
datatype="FP32",
|
||||
tensor=tensor,
|
||||
)
|
||||
|
||||
|
||||
def _infer_native(
|
||||
client: triton_http.InferenceServerClient,
|
||||
image_bgr: np.ndarray[Any, np.dtype[np.uint8]],
|
||||
) -> tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]:
|
||||
tensor = np.ascontiguousarray(image_bgr[None], dtype=np.uint8)
|
||||
return _infer(
|
||||
client,
|
||||
model_name="rf_detr_large_native_kb4",
|
||||
input_name="raw_kb4_bgr",
|
||||
datatype="UINT8",
|
||||
tensor=tensor,
|
||||
)
|
||||
|
||||
|
||||
def _infer(
|
||||
client: triton_http.InferenceServerClient,
|
||||
*,
|
||||
model_name: str,
|
||||
input_name: str,
|
||||
datatype: str,
|
||||
tensor: np.ndarray[Any, Any],
|
||||
) -> tuple[np.ndarray[Any, Any], np.ndarray[Any, Any]]:
|
||||
request = triton_http.InferInput(input_name, list(tensor.shape), datatype)
|
||||
request.set_data_from_numpy(tensor, binary_data=True)
|
||||
response = client.infer(
|
||||
model_name,
|
||||
[request],
|
||||
model_version="1",
|
||||
outputs=[
|
||||
triton_http.InferRequestedOutput("dets", binary_data=True),
|
||||
triton_http.InferRequestedOutput("labels", binary_data=True),
|
||||
],
|
||||
)
|
||||
boxes = response.as_numpy("dets")
|
||||
logits = response.as_numpy("labels")
|
||||
if not isinstance(boxes, np.ndarray) or not isinstance(logits, np.ndarray):
|
||||
raise RuntimeError("Triton RF-DETR output is missing")
|
||||
return boxes, logits
|
||||
|
||||
|
||||
def _client(origin: str) -> triton_http.InferenceServerClient:
|
||||
endpoint = urlsplit(origin)
|
||||
if endpoint.scheme != "http" or not endpoint.hostname or endpoint.path not in ("", "/"):
|
||||
raise RuntimeError("Triton endpoint must be an HTTP origin")
|
||||
return triton_http.InferenceServerClient(
|
||||
url=f"{endpoint.hostname}:{endpoint.port or 80}", verbose=False
|
||||
)
|
||||
|
||||
|
||||
def _append_timing(
|
||||
destination: dict[str, list[float]],
|
||||
started: int,
|
||||
inference_started: int,
|
||||
postprocess_started: int,
|
||||
completed: int,
|
||||
*,
|
||||
first_key: str,
|
||||
) -> None:
|
||||
destination[first_key].append((inference_started - started) / 1_000_000)
|
||||
destination["inference_transport"].append(
|
||||
(postprocess_started - inference_started) / 1_000_000
|
||||
)
|
||||
destination["postprocess"].append((completed - postprocess_started) / 1_000_000)
|
||||
destination["total"].append((completed - started) / 1_000_000)
|
||||
|
||||
|
||||
def _valid_fraction(
|
||||
box: np.ndarray[Any, np.dtype[np.float32]],
|
||||
integral: np.ndarray[Any, np.dtype[np.int64]],
|
||||
) -> tuple[float, bool, float]:
|
||||
x1 = int(np.clip(math.floor(float(box[0])), 0, SOURCE_WIDTH))
|
||||
y1 = int(np.clip(math.floor(float(box[1])), 0, SOURCE_HEIGHT))
|
||||
x2 = int(np.clip(math.ceil(float(box[2])), 0, SOURCE_WIDTH))
|
||||
y2 = int(np.clip(math.ceil(float(box[3])), 0, SOURCE_HEIGHT))
|
||||
area = float(max(0, x2 - x1) * max(0, y2 - y1))
|
||||
if area <= 0:
|
||||
return 0.0, False, 0.0
|
||||
inside = integral[y2, x2] - integral[y1, x2] - integral[y2, x1] + integral[y1, x1]
|
||||
center_x = int(np.clip(round((float(box[0]) + float(box[2])) / 2), 0, SOURCE_WIDTH - 1))
|
||||
center_y = int(
|
||||
np.clip(round((float(box[1]) + float(box[3])) / 2), 0, SOURCE_HEIGHT - 1)
|
||||
)
|
||||
center_inside = bool(
|
||||
integral[center_y + 1, center_x + 1]
|
||||
- integral[center_y, center_x + 1]
|
||||
- integral[center_y + 1, center_x]
|
||||
+ integral[center_y, center_x]
|
||||
)
|
||||
return float(inside) / area, center_inside, area
|
||||
|
||||
|
||||
def _box_iou(
|
||||
left: tuple[float, float, float, float],
|
||||
right: tuple[float, float, float, float],
|
||||
) -> float:
|
||||
intersection_width = max(0.0, min(left[2], right[2]) - max(left[0], right[0]))
|
||||
intersection_height = max(0.0, min(left[3], right[3]) - max(left[1], right[1]))
|
||||
intersection = intersection_width * intersection_height
|
||||
left_area = (left[2] - left[0]) * (left[3] - left[1])
|
||||
right_area = (right[2] - right[0]) * (right[3] - right[1])
|
||||
union = left_area + right_area - intersection
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def _distribution(values: list[float]) -> dict[str, float]:
|
||||
ordered = sorted(values)
|
||||
return {
|
||||
"mean": round(statistics.fmean(ordered), 6),
|
||||
"p50": round(_percentile(ordered, 0.5), 6),
|
||||
"p95": round(_percentile(ordered, 0.95), 6),
|
||||
"p99": round(_percentile(ordered, 0.99), 6),
|
||||
"maximum": round(ordered[-1], 6),
|
||||
}
|
||||
|
||||
|
||||
def _percentile(values: list[float], quantile: float) -> float:
|
||||
position = (len(values) - 1) * quantile
|
||||
lower = math.floor(position)
|
||||
upper = math.ceil(position)
|
||||
if lower == upper:
|
||||
return values[lower]
|
||||
return values[lower] + (values[upper] - values[lower]) * (position - lower)
|
||||
|
||||
|
||||
def sha256_path(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 canonical_json(value: object) -> bytes:
|
||||
return canonical_json_text(value).encode("utf-8")
|
||||
|
||||
|
||||
def canonical_json_text(value: object) -> str:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -25,6 +25,7 @@ from k1link.perception.detector import (
|
||||
DetectorFrameTiming,
|
||||
DetectorProviderSnapshot,
|
||||
DetectorWarmupSnapshot,
|
||||
NativeRfDetrShadowDetectorProvider,
|
||||
RfDetrShadowDetectorProvider,
|
||||
)
|
||||
from k1link.perception.geometry import Ravnoves00GeometryAssociationProvider
|
||||
@@ -446,6 +447,7 @@ def main() -> int:
|
||||
all_pipeline_timings: list[dict[str, object]] = []
|
||||
all_decode_timings: list[DecodedFrameTiming] = []
|
||||
all_pacing_timings: list[SourcePacingTiming] = []
|
||||
detector_provider_id: str | None = None
|
||||
with (
|
||||
progress.open("x", encoding="utf-8") as progress_stream,
|
||||
frame_ledger.open("x", encoding="utf-8") as frame_ledger_stream,
|
||||
@@ -478,6 +480,11 @@ def main() -> int:
|
||||
maximum_frames=arguments.maximum_frames,
|
||||
source_rate_hz=arguments.source_rate_hz,
|
||||
) as runtime:
|
||||
current_detector_provider_id = runtime.graph.detector.provider_id
|
||||
if detector_provider_id is None:
|
||||
detector_provider_id = current_detector_provider_id
|
||||
elif detector_provider_id != current_detector_provider_id:
|
||||
raise RuntimeError("detector provider identity changed between loops")
|
||||
for stage_id, attribute in (
|
||||
("geometry", "geometry"),
|
||||
("temporal", "temporal"),
|
||||
@@ -504,7 +511,8 @@ def main() -> int:
|
||||
result = runtime.graph.run()
|
||||
loop_completed_ns = time.monotonic_ns()
|
||||
detector_snapshot = cast(
|
||||
RfDetrShadowDetectorProvider,
|
||||
RfDetrShadowDetectorProvider
|
||||
| NativeRfDetrShadowDetectorProvider,
|
||||
runtime.graph.detector,
|
||||
).snapshot()
|
||||
provider_snapshots = {
|
||||
@@ -582,6 +590,8 @@ def main() -> int:
|
||||
print(json.dumps(progress_row, sort_keys=True), flush=True)
|
||||
|
||||
completed_ns = time.monotonic_ns()
|
||||
if detector_provider_id is None:
|
||||
raise RuntimeError("detector provider identity was not observed")
|
||||
wall_seconds = (completed_ns - started_ns) / 1_000_000_000.0
|
||||
rss_after_kib = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
accounting: Counter[str] = Counter()
|
||||
@@ -674,7 +684,7 @@ def main() -> int:
|
||||
"identity": {
|
||||
"worker_id": "worker-006",
|
||||
"graph_id": "reference-perception-graph/v2",
|
||||
"detector_provider_id": "triton-rf-detr-large-coco-risk-fp16-shadow/v0",
|
||||
"detector_provider_id": detector_provider_id,
|
||||
"inputs": _input_digests(paths, arguments.detector_profile),
|
||||
"runtime_artifact_sha256": arguments.runtime_artifact_sha256,
|
||||
"runner_sha256": arguments.runner_sha256,
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReleaseRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[A-Za-z0-9._-]{1,96}$")]
|
||||
[string]$RunId,
|
||||
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\experiments\m48n-native-candidate"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Get-Sha256([string]$Path) {
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label, [bool]$Create) {
|
||||
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
|
||||
$null = New-Item -ItemType Directory -Path $Path
|
||||
}
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if (
|
||||
-not $item.PSIsContainer -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||
[IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:"
|
||||
) {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Assert-RegularFile([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
throw "$Label must be a regular file"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
|
||||
function Get-Container([string]$Name) {
|
||||
$rows = @((& docker inspect $Name) | ConvertFrom-Json)
|
||||
Assert-LastExitCode "Docker inspection for $Name"
|
||||
if ($rows.Count -ne 1) { throw "Container identity for $Name is not unique" }
|
||||
return $rows[0]
|
||||
}
|
||||
|
||||
if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") {
|
||||
throw "M48N native candidate build is pinned to DESKTOP-OPJ8J04"
|
||||
}
|
||||
|
||||
$release = Resolve-DDirectory $ReleaseRoot "M48N release root" $false
|
||||
$output = Resolve-DDirectory $OutputRoot "M48N output root" $true
|
||||
$runOutput = Join-Path $output $RunId
|
||||
if (Test-Path -LiteralPath $runOutput) { throw "M48N run output already exists" }
|
||||
$null = New-Item -ItemType Directory -Path $runOutput
|
||||
$runOutput = Resolve-DDirectory $runOutput "M48N run output" $false
|
||||
|
||||
$exporter = Assert-RegularFile (
|
||||
Join-Path $release "export_m48n_rf_detr_native_worker.py"
|
||||
) "native exporter"
|
||||
$converter = Assert-RegularFile (
|
||||
Join-Path $release "convert_m48n_rf_detr_native_onnx_fp16.py"
|
||||
) "native FP16 converter"
|
||||
$wrapper = Assert-RegularFile (
|
||||
Join-Path $release "wrap_m48n_rf_detr_native_uint8_onnx.py"
|
||||
) "native UINT8 wrapper"
|
||||
$checkpoint = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\experiments\m48t-fixed-detector-20260825T095425Z" +
|
||||
"\weights\rf-detr-large-2026.pth"
|
||||
) "RF-DETR checkpoint"
|
||||
$checkpointSha256 = Get-Sha256 $checkpoint
|
||||
if ($checkpointSha256 -cne "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38") {
|
||||
throw "RF-DETR checkpoint SHA-256 changed"
|
||||
}
|
||||
$mask = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\inputs\e2" +
|
||||
"\valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2" +
|
||||
"\mask.png"
|
||||
) "valid-FOV mask"
|
||||
$maskSha256 = Get-Sha256 $mask
|
||||
if ($maskSha256 -cne "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63") {
|
||||
throw "valid-FOV mask SHA-256 changed"
|
||||
}
|
||||
|
||||
$historical = Get-Container "ndc-mission-core-triton"
|
||||
if (-not $historical.State.Running -or $historical.State.Health.Status -cne "healthy") {
|
||||
throw "Historical Triton must remain healthy"
|
||||
}
|
||||
$historicalId = [string]$historical.Id
|
||||
$baseImage = (
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3@" +
|
||||
"sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||
)
|
||||
& docker image inspect $baseImage *> $null
|
||||
Assert-LastExitCode "pinned base image inspection"
|
||||
$baseImageId = (& docker image inspect $baseImage --format "{{.Id}}").Trim()
|
||||
Assert-LastExitCode "pinned base image identity"
|
||||
$runtimeVolume = "ndc-mission-core-m48t-upstream-parity-env"
|
||||
if (-not (& docker volume ls --quiet --filter "name=^$runtimeVolume$")) {
|
||||
throw "pinned RF-DETR dependency volume is unavailable"
|
||||
}
|
||||
$buildDepsVolume = "ndc-mission-core-m48n-native-build-deps"
|
||||
if (-not (& docker volume ls --quiet --filter "name=^$buildDepsVolume$")) {
|
||||
& docker volume create `
|
||||
--label "com.nodedc.product=mission-core" `
|
||||
--label "com.nodedc.stack=ndc-mission-core-compute" `
|
||||
--label "com.nodedc.role=bounded-rf-detr-native-build-dependencies" `
|
||||
--label "com.nodedc.managed-by=codex-bounded-experiment" `
|
||||
$buildDepsVolume *> $null
|
||||
Assert-LastExitCode "M48N build dependency volume creation"
|
||||
}
|
||||
$buildDepsReady = $false
|
||||
$strictErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
& docker run --rm `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=512m" `
|
||||
-e "PYTHONPATH=/opt/build-deps:/opt/parity" `
|
||||
-v ($buildDepsVolume + ":/opt/build-deps:ro") `
|
||||
-v ($runtimeVolume + ":/opt/parity:ro") `
|
||||
--entrypoint python3 `
|
||||
$baseImage `
|
||||
-c (
|
||||
"import importlib.metadata as m, onnx, onnxconverter_common; " +
|
||||
"assert m.version('onnx') == '1.22.0'; " +
|
||||
"assert m.version('onnxconverter-common') == '1.16.0'; " +
|
||||
"assert m.version('protobuf') == '6.33.6'"
|
||||
) *> $null
|
||||
if ($LASTEXITCODE -eq 0) { $buildDepsReady = $true }
|
||||
$ErrorActionPreference = $strictErrorActionPreference
|
||||
if (-not $buildDepsReady) {
|
||||
& docker run --rm `
|
||||
--name "ndc-mission-core-m48n-native-build-deps-init" `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--pids-limit 256 `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=2g" `
|
||||
-e "PIP_DISABLE_PIP_VERSION_CHECK=1" `
|
||||
-v ($buildDepsVolume + ":/opt/build-deps:rw") `
|
||||
--entrypoint python3 `
|
||||
$baseImage `
|
||||
-m pip install --no-cache-dir --no-deps --upgrade --target /opt/build-deps `
|
||||
"onnx==1.22.0" "onnxconverter-common==1.16.0" "protobuf==6.33.6"
|
||||
Assert-LastExitCode "M48N build dependency initialization"
|
||||
}
|
||||
& docker run --rm `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=512m" `
|
||||
-e "PYTHONPATH=/opt/build-deps:/opt/parity" `
|
||||
-v ($buildDepsVolume + ":/opt/build-deps:ro") `
|
||||
-v ($runtimeVolume + ":/opt/parity:ro") `
|
||||
--entrypoint python3 `
|
||||
$baseImage `
|
||||
-c (
|
||||
"import importlib.metadata as m, onnx, onnxconverter_common; " +
|
||||
"assert m.version('onnx') == '1.22.0'; " +
|
||||
"assert m.version('onnxconverter-common') == '1.16.0'; " +
|
||||
"assert m.version('protobuf') == '6.33.6'"
|
||||
)
|
||||
Assert-LastExitCode "M48N build dependency verification"
|
||||
|
||||
$releaseMount = (Convert-ToDockerPath $release) + ":/release:ro"
|
||||
$outputMount = (Convert-ToDockerPath $runOutput) + ":/output:rw"
|
||||
$checkpointMount = (Convert-ToDockerPath $checkpoint) + ":/model/rf-detr-large-2026.pth:ro"
|
||||
$maskMount = (Convert-ToDockerPath $mask) + ":/input/mask.png:ro"
|
||||
$common = @(
|
||||
"run", "--rm",
|
||||
"--read-only",
|
||||
"--security-opt", "no-new-privileges:true",
|
||||
"--cap-drop", "ALL",
|
||||
"--pids-limit", "512",
|
||||
"--gpus", "all",
|
||||
"--shm-size", "2g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=8g",
|
||||
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
||||
"-e", "PYTHONPATH=/opt/build-deps:/opt/parity",
|
||||
"-v", ($buildDepsVolume + ":/opt/build-deps:ro"),
|
||||
"-v", ($runtimeVolume + ":/opt/parity:ro"),
|
||||
"-v", $releaseMount,
|
||||
"-v", $outputMount,
|
||||
"-v", $checkpointMount,
|
||||
"-v", $maskMount
|
||||
)
|
||||
|
||||
try {
|
||||
& docker @common `
|
||||
--name "ndc-mission-core-m48n-native-export" `
|
||||
--entrypoint python3 `
|
||||
$baseImage `
|
||||
/release/export_m48n_rf_detr_native_worker.py `
|
||||
--checkpoint /model/rf-detr-large-2026.pth `
|
||||
--expected-checkpoint-sha256 $checkpointSha256 `
|
||||
--output-root /output/core-export `
|
||||
--manifest /output/export-manifest.json `
|
||||
--upstream-revision 9b009fa928d6218320439803d1da01869a85c072
|
||||
Assert-LastExitCode "M48N native core export"
|
||||
|
||||
$exportManifest = Get-Content -LiteralPath (
|
||||
Join-Path $runOutput "export-manifest.json"
|
||||
) -Raw | ConvertFrom-Json
|
||||
$corePath = [string]$exportManifest.onnx.path
|
||||
$coreLeaf = Split-Path -Leaf $corePath
|
||||
$coreHostPath = Assert-RegularFile (
|
||||
Join-Path (Join-Path $runOutput "core-export") $coreLeaf
|
||||
) "native core ONNX"
|
||||
if ((Get-Sha256 $coreHostPath) -cne [string]$exportManifest.onnx.sha256) {
|
||||
throw "native core ONNX hash does not match its manifest"
|
||||
}
|
||||
|
||||
& docker @common `
|
||||
--name "ndc-mission-core-m48n-native-fp16" `
|
||||
--entrypoint python3 `
|
||||
$baseImage `
|
||||
/release/convert_m48n_rf_detr_native_onnx_fp16.py `
|
||||
--input ("/output/core-export/" + $coreLeaf) `
|
||||
--expected-input-sha256 ([string]$exportManifest.onnx.sha256) `
|
||||
--output /output/rf-detr-native-core-fp16.onnx `
|
||||
--manifest /output/fp16-manifest.json
|
||||
Assert-LastExitCode "M48N native FP16 conversion"
|
||||
|
||||
$fp16Manifest = Get-Content -LiteralPath (
|
||||
Join-Path $runOutput "fp16-manifest.json"
|
||||
) -Raw | ConvertFrom-Json
|
||||
$fp16Path = Assert-RegularFile (
|
||||
Join-Path $runOutput "rf-detr-native-core-fp16.onnx"
|
||||
) "native FP16 ONNX"
|
||||
if ((Get-Sha256 $fp16Path) -cne [string]$fp16Manifest.output_onnx_sha256) {
|
||||
throw "native FP16 ONNX hash does not match its manifest"
|
||||
}
|
||||
|
||||
& docker @common `
|
||||
--name "ndc-mission-core-m48n-native-wrapper" `
|
||||
--entrypoint python3 `
|
||||
$baseImage `
|
||||
/release/wrap_m48n_rf_detr_native_uint8_onnx.py `
|
||||
--input /output/rf-detr-native-core-fp16.onnx `
|
||||
--expected-input-sha256 ([string]$fp16Manifest.output_onnx_sha256) `
|
||||
--mask /input/mask.png `
|
||||
--expected-mask-sha256 $maskSha256 `
|
||||
--output /output/rf-detr-native-uint8.onnx `
|
||||
--manifest /output/wrapper-manifest.json
|
||||
Assert-LastExitCode "M48N native UINT8 wrapper"
|
||||
|
||||
$wrapperManifest = Get-Content -LiteralPath (
|
||||
Join-Path $runOutput "wrapper-manifest.json"
|
||||
) -Raw | ConvertFrom-Json
|
||||
$wrappedPath = Assert-RegularFile (
|
||||
Join-Path $runOutput "rf-detr-native-uint8.onnx"
|
||||
) "native UINT8 ONNX"
|
||||
if ((Get-Sha256 $wrappedPath) -cne [string]$wrapperManifest.output_onnx_sha256) {
|
||||
throw "native UINT8 ONNX hash does not match its manifest"
|
||||
}
|
||||
|
||||
& docker @common `
|
||||
--name "ndc-mission-core-m48n-native-trt-build" `
|
||||
--entrypoint /usr/src/tensorrt/bin/trtexec `
|
||||
$baseImage `
|
||||
--onnx=/output/rf-detr-native-uint8.onnx `
|
||||
--saveEngine=/output/rf-detr-native-uint8.plan `
|
||||
--stronglyTyped `
|
||||
--builderOptimizationLevel=5 `
|
||||
--memPoolSize=workspace:8192 `
|
||||
--skipInference
|
||||
Assert-LastExitCode "M48N native TensorRT engine build"
|
||||
|
||||
$enginePath = Assert-RegularFile (
|
||||
Join-Path $runOutput "rf-detr-native-uint8.plan"
|
||||
) "native TensorRT engine"
|
||||
$receipt = [ordered]@{
|
||||
schema_version = "missioncore.m48n-native-candidate-build/v0"
|
||||
run_id = $RunId
|
||||
completed = $true
|
||||
worker = [ordered]@{
|
||||
id = "worker-006"
|
||||
node = $env:COMPUTERNAME
|
||||
base_image = $baseImage
|
||||
base_image_id = $baseImageId
|
||||
dependency_volume = $runtimeVolume
|
||||
build_dependency_volume = $buildDepsVolume
|
||||
}
|
||||
artifacts = [ordered]@{
|
||||
export_manifest_sha256 = Get-Sha256 (
|
||||
Join-Path $runOutput "export-manifest.json"
|
||||
)
|
||||
core_onnx_sha256 = Get-Sha256 $coreHostPath
|
||||
fp16_manifest_sha256 = Get-Sha256 (
|
||||
Join-Path $runOutput "fp16-manifest.json"
|
||||
)
|
||||
fp16_onnx_sha256 = Get-Sha256 $fp16Path
|
||||
wrapper_manifest_sha256 = Get-Sha256 (
|
||||
Join-Path $runOutput "wrapper-manifest.json"
|
||||
)
|
||||
wrapped_onnx_sha256 = Get-Sha256 $wrappedPath
|
||||
engine_sha256 = Get-Sha256 $enginePath
|
||||
engine_size_bytes = (Get-Item -LiteralPath $enginePath).Length
|
||||
}
|
||||
historical_triton = [ordered]@{
|
||||
container_id = $historicalId
|
||||
action = "none"
|
||||
}
|
||||
authority = [ordered]@{
|
||||
ground_truth = $false
|
||||
candidate_accepted = $false
|
||||
commands_enabled = $false
|
||||
actuation_allowed = $false
|
||||
navigation_or_safety_accepted = $false
|
||||
}
|
||||
}
|
||||
$receipt | ConvertTo-Json -Depth 8 -Compress | Set-Content -LiteralPath (
|
||||
Join-Path $runOutput "build-receipt.json"
|
||||
) -Encoding UTF8
|
||||
} finally {
|
||||
foreach ($name in @(
|
||||
"ndc-mission-core-m48n-native-export",
|
||||
"ndc-mission-core-m48n-native-fp16",
|
||||
"ndc-mission-core-m48n-native-wrapper",
|
||||
"ndc-mission-core-m48n-native-trt-build"
|
||||
)) {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||
& docker rm -f $name *> $null
|
||||
}
|
||||
}
|
||||
$historicalAfter = Get-Container "ndc-mission-core-triton"
|
||||
if (
|
||||
$historicalAfter.Id -cne $historicalId -or
|
||||
-not $historicalAfter.State.Running -or
|
||||
$historicalAfter.State.Health.Status -cne "healthy"
|
||||
) {
|
||||
throw "Historical Triton changed during M48N candidate build"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ("M48N_NATIVE_BUILD_RECEIPT={0}" -f (
|
||||
Join-Path $runOutput "build-receipt.json"
|
||||
))
|
||||
Write-Output "HISTORICAL_TRITON_ACTION=none"
|
||||
Write-Output "SEMANTIC_AUTHORITY_CHANGED=false"
|
||||
@@ -0,0 +1,238 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReleaseRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CandidateRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[A-Za-z0-9._-]{1,96}$")]
|
||||
[string]$RunId,
|
||||
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m48n-native-parity"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Get-Sha256([string]$Path) {
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label, [bool]$Create) {
|
||||
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
|
||||
$null = New-Item -ItemType Directory -Path $Path
|
||||
}
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if (
|
||||
-not $item.PSIsContainer -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||
[IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:"
|
||||
) {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Assert-RegularFile([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
throw "$Label must be a regular file"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
|
||||
function Get-Container([string]$Name) {
|
||||
$rows = @((& docker inspect $Name) | ConvertFrom-Json)
|
||||
Assert-LastExitCode "Docker inspection for $Name"
|
||||
if ($rows.Count -ne 1) { throw "Container identity for $Name is not unique" }
|
||||
return $rows[0]
|
||||
}
|
||||
|
||||
if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") {
|
||||
throw "M48N native parity is pinned to DESKTOP-OPJ8J04"
|
||||
}
|
||||
|
||||
$release = Resolve-DDirectory $ReleaseRoot "M48N release root" $false
|
||||
$candidate = Resolve-DDirectory $CandidateRoot "M48N candidate root" $false
|
||||
$output = Resolve-DDirectory $OutputRoot "M48N parity output root" $true
|
||||
$runOutput = Join-Path $output $RunId
|
||||
if (Test-Path -LiteralPath $runOutput) { throw "M48N parity output already exists" }
|
||||
$null = New-Item -ItemType Directory -Path $runOutput
|
||||
$runOutput = Resolve-DDirectory $runOutput "M48N parity run output" $false
|
||||
|
||||
$runner = Assert-RegularFile (
|
||||
Join-Path $release "run_m48n_native_parity_worker.py"
|
||||
) "native parity runner"
|
||||
$modelConfig = Assert-RegularFile (
|
||||
Join-Path $release "rf_detr_large_native_kb4_config.pbtxt"
|
||||
) "native Triton config"
|
||||
$engine = Assert-RegularFile (
|
||||
Join-Path $candidate "rf-detr-native-uint8.plan"
|
||||
) "native TensorRT engine"
|
||||
$engineSha256 = Get-Sha256 $engine
|
||||
if ($engineSha256 -cne "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695") {
|
||||
throw "native TensorRT engine SHA-256 changed"
|
||||
}
|
||||
$video = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\experiments\e46e\inputs" +
|
||||
"\right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8.mp4"
|
||||
) "RAVNOVES00 video"
|
||||
$videoSha256 = Get-Sha256 $video
|
||||
if ($videoSha256 -cne "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8") {
|
||||
throw "RAVNOVES00 video SHA-256 changed"
|
||||
}
|
||||
$mask = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\inputs\e2" +
|
||||
"\valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2" +
|
||||
"\mask.png"
|
||||
) "valid-FOV mask"
|
||||
$maskSha256 = Get-Sha256 $mask
|
||||
if ($maskSha256 -cne "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63") {
|
||||
throw "valid-FOV mask SHA-256 changed"
|
||||
}
|
||||
$checkpoint = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\experiments\m48t-fixed-detector-20260825T095425Z" +
|
||||
"\weights\rf-detr-large-2026.pth"
|
||||
) "RF-DETR checkpoint"
|
||||
$checkpointSha256 = Get-Sha256 $checkpoint
|
||||
if ($checkpointSha256 -cne "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38") {
|
||||
throw "RF-DETR checkpoint SHA-256 changed"
|
||||
}
|
||||
|
||||
$historical = Get-Container "ndc-mission-core-triton"
|
||||
if (-not $historical.State.Running -or $historical.State.Health.Status -cne "healthy") {
|
||||
throw "Historical Triton must remain healthy"
|
||||
}
|
||||
$historicalId = [string]$historical.Id
|
||||
$baseImage = (
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3@" +
|
||||
"sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||
)
|
||||
& docker image inspect $baseImage *> $null
|
||||
Assert-LastExitCode "pinned base image inspection"
|
||||
$runtimeVolume = "ndc-mission-core-m48t-upstream-parity-env"
|
||||
if (-not (& docker volume ls --quiet --filter "name=^$runtimeVolume$")) {
|
||||
throw "pinned RF-DETR dependency volume is unavailable"
|
||||
}
|
||||
|
||||
$modelRoot = Join-Path $runOutput "triton-models"
|
||||
$modelDirectory = Join-Path $modelRoot "rf_detr_large_native_kb4"
|
||||
$modelVersionDirectory = Join-Path $modelDirectory "1"
|
||||
$null = New-Item -ItemType Directory -Path $modelVersionDirectory
|
||||
Copy-Item -LiteralPath $modelConfig -Destination (Join-Path $modelDirectory "config.pbtxt")
|
||||
Copy-Item -LiteralPath $engine -Destination (Join-Path $modelVersionDirectory "model.plan")
|
||||
if ((Get-Sha256 (Join-Path $modelVersionDirectory "model.plan")) -cne $engineSha256) {
|
||||
throw "staged native TensorRT engine SHA-256 changed"
|
||||
}
|
||||
|
||||
$tritonName = "ndc-mission-core-m48n-native-parity-triton"
|
||||
$runnerName = "ndc-mission-core-m48n-native-parity"
|
||||
foreach ($name in @($tritonName, $runnerName)) {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||
throw "M48N parity container $name already exists"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
& docker create `
|
||||
--name $tritonName `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--pids-limit 512 `
|
||||
--shm-size 1g `
|
||||
--gpus all `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=2g" `
|
||||
--health-cmd "curl --fail --silent http://127.0.0.1:8000/v2/health/ready" `
|
||||
--health-interval 5s `
|
||||
--health-timeout 3s `
|
||||
--health-start-period 20s `
|
||||
--health-retries 24 `
|
||||
-v ((Convert-ToDockerPath $modelRoot) + ":/models:ro") `
|
||||
$baseImage `
|
||||
tritonserver `
|
||||
--model-repository=/models `
|
||||
--model-control-mode=explicit `
|
||||
--load-model=rf_detr_large_native_kb4 `
|
||||
--disable-auto-complete-config `
|
||||
--strict-readiness=true `
|
||||
--exit-on-error=true `
|
||||
--allow-http=true `
|
||||
--allow-grpc=false `
|
||||
--allow-metrics=false *> $null
|
||||
Assert-LastExitCode "M48N parity Triton creation"
|
||||
& docker start $tritonName *> $null
|
||||
Assert-LastExitCode "M48N parity Triton start"
|
||||
$ready = $false
|
||||
foreach ($attempt in 1..60) {
|
||||
Start-Sleep -Seconds 2
|
||||
$candidateContainer = Get-Container $tritonName
|
||||
if (-not $candidateContainer.State.Running) {
|
||||
& docker logs $tritonName
|
||||
throw "M48N parity Triton stopped during startup"
|
||||
}
|
||||
if ($candidateContainer.State.Health.Status -ceq "healthy") {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $ready) { throw "M48N parity Triton did not become healthy" }
|
||||
|
||||
& docker run `
|
||||
--name $runnerName `
|
||||
--network ("container:{0}" -f $tritonName) `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--pids-limit 512 `
|
||||
--gpus all `
|
||||
--shm-size 2g `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=8g" `
|
||||
-e "PYTHONDONTWRITEBYTECODE=1" `
|
||||
-e "PYTHONPATH=/opt/parity" `
|
||||
-v ($runtimeVolume + ":/opt/parity:ro") `
|
||||
-v ((Convert-ToDockerPath $release) + ":/release:ro") `
|
||||
-v ((Convert-ToDockerPath $video) + ":/input/video.mp4:ro") `
|
||||
-v ((Convert-ToDockerPath $mask) + ":/input/mask.png:ro") `
|
||||
-v ((Convert-ToDockerPath $checkpoint) + ":/model/rf-detr-large-2026.pth:ro") `
|
||||
-v ((Convert-ToDockerPath $runOutput) + ":/output:rw") `
|
||||
--entrypoint python3 `
|
||||
$baseImage `
|
||||
/release/run_m48n_native_parity_worker.py `
|
||||
--video /input/video.mp4 `
|
||||
--expected-video-sha256 $videoSha256 `
|
||||
--mask /input/mask.png `
|
||||
--expected-mask-sha256 $maskSha256 `
|
||||
--checkpoint /model/rf-detr-large-2026.pth `
|
||||
--expected-checkpoint-sha256 $checkpointSha256 `
|
||||
--engine-sha256 $engineSha256 `
|
||||
--triton-origin http://127.0.0.1:8000 `
|
||||
--output /output/result.json
|
||||
Assert-LastExitCode "M48N native PyTorch/TensorRT parity"
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $runOutput "result.json") -PathType Leaf)) {
|
||||
throw "M48N native parity result was not written"
|
||||
}
|
||||
} finally {
|
||||
foreach ($name in @($runnerName, $tritonName)) {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||
& docker rm -f $name *> $null
|
||||
}
|
||||
}
|
||||
$historicalAfter = Get-Container "ndc-mission-core-triton"
|
||||
if (
|
||||
$historicalAfter.Id -cne $historicalId -or
|
||||
-not $historicalAfter.State.Running -or
|
||||
$historicalAfter.State.Health.Status -cne "healthy"
|
||||
) {
|
||||
throw "Historical Triton changed during M48N parity"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ("M48N_NATIVE_PARITY_RESULT={0}" -f (Join-Path $runOutput "result.json"))
|
||||
Write-Output "HISTORICAL_TRITON_ACTION=none"
|
||||
Write-Output "SEMANTIC_AUTHORITY_CHANGED=false"
|
||||
@@ -0,0 +1,360 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReleaseRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CandidateRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[a-f0-9]{64}$")]
|
||||
[string]$ExpectedWheelSha256,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[A-Za-z0-9._-]{1,96}$")]
|
||||
[string]$RunId,
|
||||
[ValidateRange(1, 4489)]
|
||||
[int]$MaximumFrames = 300,
|
||||
[ValidateRange(1.0, 120.0)]
|
||||
[double]$SourceRateHz = 10.0,
|
||||
[ValidateRange(0.0, 1.0)]
|
||||
[double]$MinimumDeliveryRatio = 0.999,
|
||||
[ValidateRange(0.1, 120.0)]
|
||||
[double]$MinimumEffectiveWorldStateFps = 9.5,
|
||||
[ValidateRange(1.0, 10000.0)]
|
||||
[double]$MaximumWorldStateCompletionP95Ms = 125.0,
|
||||
[string]$OutputRoot = (
|
||||
"D:\NDC_MISSIONCORE\runtime\results\m48n-native-reference-graph-shadow"
|
||||
)
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Get-Sha256([string]$Path) {
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Assert-File([string]$Path, [string]$ExpectedSha256, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
throw "$Label must be a regular file"
|
||||
}
|
||||
if ((Get-Sha256 $item.FullName) -cne $ExpectedSha256) {
|
||||
throw "$Label SHA-256 changed"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label, [bool]$Create) {
|
||||
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
|
||||
$null = New-Item -ItemType Directory -Path $Path
|
||||
}
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if (
|
||||
-not $item.PSIsContainer -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||
[IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:"
|
||||
) {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
|
||||
function Get-Container([string]$Name) {
|
||||
$rows = @((& docker inspect $Name) | ConvertFrom-Json)
|
||||
Assert-LastExitCode "Docker inspection for $Name"
|
||||
if ($rows.Count -ne 1) { throw "Container identity for $Name is not unique" }
|
||||
return $rows[0]
|
||||
}
|
||||
|
||||
if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") {
|
||||
throw "M48N native reference graph is pinned to DESKTOP-OPJ8J04"
|
||||
}
|
||||
|
||||
$release = Resolve-DDirectory $ReleaseRoot "M48N release root" $false
|
||||
$candidate = Resolve-DDirectory $CandidateRoot "M48N candidate root" $false
|
||||
$output = Resolve-DDirectory $OutputRoot "M48N output root" $true
|
||||
$runOutput = Join-Path $output $RunId
|
||||
if (Test-Path -LiteralPath $runOutput) { throw "M48N run output already exists" }
|
||||
$null = New-Item -ItemType Directory -Path $runOutput
|
||||
$runOutput = Resolve-DDirectory $runOutput "M48N run output" $false
|
||||
|
||||
$wheel = Assert-File (
|
||||
Join-Path $release "nodedc_mission_core-0.1.0-py3-none-any.whl"
|
||||
) $ExpectedWheelSha256 "M48N wheel"
|
||||
$expectedConfigs = [ordered]@{
|
||||
"m48n-rf-detr-native-reference-graph-shadow-v0.json" = (
|
||||
"336dccb6b64f4fa5def14aae967fd3280cc1720e93fa3ee21885e0c3304cee4d"
|
||||
)
|
||||
"m4-recorded-realtime-baseline-v1.json" = (
|
||||
"ea10359339e6cce31b5780a2710299771cab7cc0c1c2a2b56a1621f786b31fa8"
|
||||
)
|
||||
"rf-detr-large-native-kb4-risk-shadow-v0.json" = (
|
||||
"398b1102943e704b08a033b1d04b6bdb039ecdd73a2b2e370a0a9cbcaff501ec"
|
||||
)
|
||||
"m4-geometry-association-v1.json" = (
|
||||
"cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"
|
||||
)
|
||||
"m4-temporal-motion-v1.json" = (
|
||||
"7130eaee24a95c7d888bf7598010e03e129e1c3ac5b34bcd8401015ff4244b39"
|
||||
)
|
||||
"m4-rolling-local-map-v1.json" = (
|
||||
"f7e3315eaf6ffaf3aee1e04913933812092cf82bbcc9984c1a6fa2d9250e6784"
|
||||
)
|
||||
"m4-replay-threat-v3.json" = (
|
||||
"8c3a5aa837da1f028f5998fb504a1381f9b2b68de6420a32160410b6dc0887c7"
|
||||
)
|
||||
}
|
||||
foreach ($entry in $expectedConfigs.GetEnumerator()) {
|
||||
$null = Assert-File (Join-Path $release $entry.Key) $entry.Value (
|
||||
"M48N config {0}" -f $entry.Key
|
||||
)
|
||||
}
|
||||
$runner = Get-Item -LiteralPath (
|
||||
Join-Path $release "run_m48s_reference_graph_shadow_worker.py"
|
||||
)
|
||||
if ($runner.PSIsContainer -or ($runner.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
throw "M48N graph runner must be a regular file"
|
||||
}
|
||||
$runnerSha256 = Get-Sha256 $runner.FullName
|
||||
$nativeConfig = Assert-File (
|
||||
Join-Path $release "rf_detr_large_native_kb4_config.pbtxt"
|
||||
) "15e100029df92c1390c567517eac6d8bf640591c865bf87a3955289292ba3a22" (
|
||||
"native RF-DETR Triton config"
|
||||
)
|
||||
$nativeEngine = Assert-File (
|
||||
Join-Path $candidate "rf-detr-native-uint8.plan"
|
||||
) "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695" (
|
||||
"native RF-DETR TensorRT engine"
|
||||
)
|
||||
|
||||
$modelRoot = Join-Path $runOutput "triton-models"
|
||||
$modelDirectory = Join-Path $modelRoot "rf_detr_large_native_kb4"
|
||||
$modelVersionDirectory = Join-Path $modelDirectory "1"
|
||||
$null = New-Item -ItemType Directory -Path $modelVersionDirectory
|
||||
Copy-Item -LiteralPath $nativeConfig -Destination (Join-Path $modelDirectory "config.pbtxt")
|
||||
Copy-Item -LiteralPath $nativeEngine -Destination (Join-Path $modelVersionDirectory "model.plan")
|
||||
if (
|
||||
(Get-Sha256 (Join-Path $modelVersionDirectory "model.plan")) -cne
|
||||
"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"
|
||||
) {
|
||||
throw "staged native RF-DETR TensorRT engine SHA-256 changed"
|
||||
}
|
||||
|
||||
$source = [ordered]@{
|
||||
CameraIndex = (
|
||||
"D:\NDC_MISSIONCORE\runtime\jobs\recorded-camera-602ac89026ed12978619801d" +
|
||||
"\input\camera\sensor.camera.right\epoch-1\index.jsonl"
|
||||
)
|
||||
SourcePack = (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived" +
|
||||
"\e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b" +
|
||||
"\lidar-pack.npz"
|
||||
)
|
||||
LocalSurface = (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived" +
|
||||
"\k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55" +
|
||||
"\local-surface.npz"
|
||||
)
|
||||
Video = (
|
||||
"D:\NDC_MISSIONCORE\runtime\experiments\e46e\inputs" +
|
||||
"\right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8.mp4"
|
||||
)
|
||||
Mask = (
|
||||
"D:\NDC_MISSIONCORE\runtime\inputs\e2" +
|
||||
"\valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2" +
|
||||
"\mask.png"
|
||||
)
|
||||
}
|
||||
foreach ($entry in $source.GetEnumerator()) {
|
||||
if (-not (Test-Path -LiteralPath $entry.Value -PathType Leaf)) {
|
||||
throw "M48N source $($entry.Key) is missing"
|
||||
}
|
||||
}
|
||||
if ((Get-Sha256 $source.Video) -cne "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8") {
|
||||
throw "RAVNOVES00 video SHA-256 changed"
|
||||
}
|
||||
if ((Get-Sha256 $source.Mask) -cne "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63") {
|
||||
throw "valid-FOV mask SHA-256 changed"
|
||||
}
|
||||
|
||||
$media = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived\perception-e15-media-pyav180-lz445-v1"
|
||||
) "PyAV dependency" $false
|
||||
$opencv = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived\perception-e3-opencv413092-v1\packages"
|
||||
) "OpenCV dependency" $false
|
||||
$pillow = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived\perception-p0-env-v1"
|
||||
) "Pillow dependency" $false
|
||||
|
||||
$image = (
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3@" +
|
||||
"sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||
)
|
||||
& docker image inspect $image *> $null
|
||||
Assert-LastExitCode "pinned M48N image inspection"
|
||||
$canonicalTriton = Get-Container "ndc-mission-core-triton"
|
||||
if (-not $canonicalTriton.State.Running -or $canonicalTriton.State.Health.Status -cne "healthy") {
|
||||
throw "Canonical Triton must remain healthy during M48N shadow"
|
||||
}
|
||||
$canonicalTritonId = [string]$canonicalTriton.Id
|
||||
$tritonName = "ndc-mission-core-m48n-native-reference-graph-triton"
|
||||
$graphName = "ndc-mission-core-m48n-native-reference-graph"
|
||||
foreach ($name in @($tritonName, $graphName)) {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||
throw "M48N candidate container $name already exists"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
& docker create `
|
||||
--name $tritonName `
|
||||
--label "com.nodedc.product=mission-core" `
|
||||
--label "com.nodedc.stack=ndc-mission-core-compute" `
|
||||
--label "com.nodedc.role=bounded-native-rf-detr-reference-graph-triton" `
|
||||
--label "com.nodedc.managed-by=codex-bounded-experiment" `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--pids-limit 512 `
|
||||
--shm-size 1g `
|
||||
--gpus all `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=2g" `
|
||||
--health-cmd "curl --fail --silent http://127.0.0.1:8000/v2/health/ready" `
|
||||
--health-interval 5s `
|
||||
--health-timeout 3s `
|
||||
--health-start-period 20s `
|
||||
--health-retries 24 `
|
||||
-v ((Convert-ToDockerPath $modelRoot) + ":/models:ro") `
|
||||
$image `
|
||||
tritonserver `
|
||||
--model-repository=/models `
|
||||
--model-control-mode=explicit `
|
||||
--load-model=rf_detr_large_native_kb4 `
|
||||
--disable-auto-complete-config `
|
||||
--strict-readiness=true `
|
||||
--exit-on-error=true `
|
||||
--allow-http=true `
|
||||
--allow-grpc=false `
|
||||
--allow-metrics=false *> $null
|
||||
Assert-LastExitCode "M48N Triton creation"
|
||||
& docker start $tritonName *> $null
|
||||
Assert-LastExitCode "M48N Triton start"
|
||||
$ready = $false
|
||||
foreach ($attempt in 1..60) {
|
||||
Start-Sleep -Seconds 2
|
||||
$candidateContainer = Get-Container $tritonName
|
||||
if (-not $candidateContainer.State.Running) {
|
||||
& docker logs $tritonName
|
||||
throw "M48N Triton stopped during startup"
|
||||
}
|
||||
if ($candidateContainer.State.Health.Status -ceq "healthy") {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $ready) { throw "M48N Triton did not become healthy" }
|
||||
if (@((Get-Container $tritonName).HostConfig.PortBindings.PSObject.Properties).Count -ne 0) {
|
||||
throw "M48N Triton published a host port"
|
||||
}
|
||||
|
||||
$arguments = @(
|
||||
"run", "--name", $graphName,
|
||||
"--label", "com.nodedc.product=mission-core",
|
||||
"--label", "com.nodedc.stack=ndc-mission-core-compute",
|
||||
"--label", "com.nodedc.role=bounded-native-rf-detr-reference-graph",
|
||||
"--label", "com.nodedc.managed-by=codex-bounded-experiment",
|
||||
"--network", ("container:{0}" -f $tritonName),
|
||||
"--read-only",
|
||||
"--security-opt", "no-new-privileges:true",
|
||||
"--cap-drop", "ALL",
|
||||
"--pids-limit", "256",
|
||||
"--gpus", "all",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
||||
"-e", (
|
||||
"PYTHONPATH=/release/nodedc_mission_core-0.1.0-py3-none-any.whl:" +
|
||||
"/opt/media:/opt/opencv:/opt/pillow"
|
||||
),
|
||||
"-v", ((Convert-ToDockerPath $release) + ":/release:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runOutput) + ":/output:rw"),
|
||||
"-v", ((Convert-ToDockerPath $media) + ":/opt/media:ro"),
|
||||
"-v", ((Convert-ToDockerPath $opencv) + ":/opt/opencv:ro"),
|
||||
"-v", ((Convert-ToDockerPath $pillow) + ":/opt/pillow:ro"),
|
||||
"-v", ((Convert-ToDockerPath $source.CameraIndex) + ":/source/camera-index.jsonl:ro"),
|
||||
"-v", ((Convert-ToDockerPath $source.SourcePack) + ":/source/source-pack.npz:ro"),
|
||||
"-v", ((Convert-ToDockerPath $source.LocalSurface) + ":/source/local-surface.npz:ro"),
|
||||
"-v", ((Convert-ToDockerPath $source.Video) + ":/source/right.mp4:ro"),
|
||||
"-v", ((Convert-ToDockerPath $source.Mask) + ":/source/mask.png:ro"),
|
||||
"--entrypoint", "python3",
|
||||
$image,
|
||||
"/release/run_m48s_reference_graph_shadow_worker.py",
|
||||
"--graph-config", "/release/m48n-rf-detr-native-reference-graph-shadow-v0.json",
|
||||
"--baseline-profile", "/release/m4-recorded-realtime-baseline-v1.json",
|
||||
"--detector-profile", "/release/rf-detr-large-native-kb4-risk-shadow-v0.json",
|
||||
"--geometry-profile", "/release/m4-geometry-association-v1.json",
|
||||
"--temporal-motion-profile", "/release/m4-temporal-motion-v1.json",
|
||||
"--rolling-map-profile", "/release/m4-rolling-local-map-v1.json",
|
||||
"--threat-profile", "/release/m4-replay-threat-v3.json",
|
||||
"--camera-index", "/source/camera-index.jsonl",
|
||||
"--source-pack", "/source/source-pack.npz",
|
||||
"--local-surface", "/source/local-surface.npz",
|
||||
"--video", "/source/right.mp4",
|
||||
"--valid-fov-mask", "/source/mask.png",
|
||||
"--triton-origin", "http://127.0.0.1:8000",
|
||||
"--loops", "1",
|
||||
"--maximum-frames", ([string]$MaximumFrames),
|
||||
"--source-rate-hz", ([string]::Format(
|
||||
[Globalization.CultureInfo]::InvariantCulture, "{0:R}", $SourceRateHz
|
||||
)),
|
||||
"--minimum-delivery-ratio", ([string]::Format(
|
||||
[Globalization.CultureInfo]::InvariantCulture, "{0:R}", $MinimumDeliveryRatio
|
||||
)),
|
||||
"--minimum-effective-world-state-fps", ([string]::Format(
|
||||
[Globalization.CultureInfo]::InvariantCulture,
|
||||
"{0:R}",
|
||||
$MinimumEffectiveWorldStateFps
|
||||
)),
|
||||
"--maximum-world-state-completion-p95-ms", ([string]::Format(
|
||||
[Globalization.CultureInfo]::InvariantCulture,
|
||||
"{0:R}",
|
||||
$MaximumWorldStateCompletionP95Ms
|
||||
)),
|
||||
"--load-purpose", "production-rate",
|
||||
"--runtime-artifact-sha256", $ExpectedWheelSha256,
|
||||
"--runner-sha256", $runnerSha256,
|
||||
"--output", "/output/result.json",
|
||||
"--progress", "/output/progress.jsonl",
|
||||
"--frame-ledger", "/output/frames.jsonl"
|
||||
)
|
||||
& docker @arguments
|
||||
Assert-LastExitCode "M48N native complete reference graph shadow"
|
||||
foreach ($name in @("result.json", "frames.jsonl", "progress.jsonl")) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $runOutput $name) -PathType Leaf)) {
|
||||
throw "M48N graph artifact $name was not written"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
foreach ($name in @($graphName, $tritonName)) {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||
& docker rm -f $name *> $null
|
||||
}
|
||||
}
|
||||
$canonicalAfter = Get-Container "ndc-mission-core-triton"
|
||||
if (
|
||||
$canonicalAfter.Id -cne $canonicalTritonId -or
|
||||
-not $canonicalAfter.State.Running -or
|
||||
$canonicalAfter.State.Health.Status -cne "healthy"
|
||||
) {
|
||||
throw "Canonical Triton changed during M48N shadow"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ("M48N_NATIVE_REFERENCE_GRAPH_RESULT={0}" -f (Join-Path $runOutput "result.json"))
|
||||
Write-Output "CANONICAL_TRITON_ACTION=none"
|
||||
Write-Output "PRODUCTION_ACCEPTED=false"
|
||||
@@ -0,0 +1,275 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReleaseRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CandidateRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[A-Za-z0-9._-]{1,96}$")]
|
||||
[string]$RunId,
|
||||
[ValidateRange(0, 1000000)]
|
||||
[int]$MaximumFrames = 0,
|
||||
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m48n-native-vs-704"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Get-Sha256([string]$Path) {
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label, [bool]$Create) {
|
||||
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
|
||||
$null = New-Item -ItemType Directory -Path $Path
|
||||
}
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if (
|
||||
-not $item.PSIsContainer -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||
[IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:"
|
||||
) {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Assert-RegularFile([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
throw "$Label must be a regular file"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
|
||||
function Get-Container([string]$Name) {
|
||||
$rows = @((& docker inspect $Name) | ConvertFrom-Json)
|
||||
Assert-LastExitCode "Docker inspection for $Name"
|
||||
if ($rows.Count -ne 1) { throw "Container identity for $Name is not unique" }
|
||||
return $rows[0]
|
||||
}
|
||||
|
||||
if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") {
|
||||
throw "M48N native/704 comparison is pinned to DESKTOP-OPJ8J04"
|
||||
}
|
||||
|
||||
$release = Resolve-DDirectory $ReleaseRoot "M48N release root" $false
|
||||
$candidate = Resolve-DDirectory $CandidateRoot "M48N candidate root" $false
|
||||
$output = Resolve-DDirectory $OutputRoot "M48N comparison output root" $true
|
||||
$runOutput = Join-Path $output $RunId
|
||||
if (Test-Path -LiteralPath $runOutput) { throw "M48N comparison output already exists" }
|
||||
$null = New-Item -ItemType Directory -Path $runOutput
|
||||
$runOutput = Resolve-DDirectory $runOutput "M48N comparison run output" $false
|
||||
|
||||
$runner = Assert-RegularFile (
|
||||
Join-Path $release "run_m48n_native_vs_704_worker.py"
|
||||
) "native/704 comparison runner"
|
||||
$nativeConfig = Assert-RegularFile (
|
||||
Join-Path $release "rf_detr_large_native_kb4_config.pbtxt"
|
||||
) "native Triton config"
|
||||
$nativeEngine = Assert-RegularFile (
|
||||
Join-Path $candidate "rf-detr-native-uint8.plan"
|
||||
) "native TensorRT engine"
|
||||
$nativeEngineSha256 = Get-Sha256 $nativeEngine
|
||||
if ($nativeEngineSha256 -cne "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695") {
|
||||
throw "native TensorRT engine SHA-256 changed"
|
||||
}
|
||||
$baselineRoot = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\experiments\m48t-fixed-detector-20260825T095425Z" +
|
||||
"\triton-models\rf_detr_large"
|
||||
) "frozen RF-DETR 704 model root" $false
|
||||
$baselineConfig = Assert-RegularFile (
|
||||
Join-Path $baselineRoot "config.pbtxt"
|
||||
) "frozen RF-DETR 704 Triton config"
|
||||
if ((Get-Sha256 $baselineConfig) -cne "80947cad235e5b000f11aa869a33af0e8c727f07e04046691468df1e171479b6") {
|
||||
throw "frozen RF-DETR 704 Triton config SHA-256 changed"
|
||||
}
|
||||
$baselineEngine = Assert-RegularFile (
|
||||
Join-Path $baselineRoot "1\model.plan"
|
||||
) "frozen RF-DETR 704 TensorRT engine"
|
||||
$baselineEngineSha256 = Get-Sha256 $baselineEngine
|
||||
if ($baselineEngineSha256 -cne "986399ce706b7380472cf5e473232249fed6e628971d8007f6609e83128d46b8") {
|
||||
throw "frozen RF-DETR 704 TensorRT engine SHA-256 changed"
|
||||
}
|
||||
$video = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\experiments\e46e\inputs" +
|
||||
"\right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8.mp4"
|
||||
) "RAVNOVES00 video"
|
||||
$videoSha256 = Get-Sha256 $video
|
||||
if ($videoSha256 -cne "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8") {
|
||||
throw "RAVNOVES00 video SHA-256 changed"
|
||||
}
|
||||
$mask = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\inputs\e2" +
|
||||
"\valid-fov-mask-b4dd8ddf2b87c1d520ee8a0868c4fea062d7c14d1bae73ccabd3abe1f3acbac2" +
|
||||
"\mask.png"
|
||||
) "valid-FOV mask"
|
||||
$maskSha256 = Get-Sha256 $mask
|
||||
if ($maskSha256 -cne "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63") {
|
||||
throw "valid-FOV mask SHA-256 changed"
|
||||
}
|
||||
$opencvPackages = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived\perception-e3-opencv413092-v1\packages"
|
||||
) "pinned OpenCV packages" $false
|
||||
|
||||
$historical = Get-Container "ndc-mission-core-triton"
|
||||
if (-not $historical.State.Running -or $historical.State.Health.Status -cne "healthy") {
|
||||
throw "Canonical Triton must remain healthy"
|
||||
}
|
||||
$historicalId = [string]$historical.Id
|
||||
$baseImage = (
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3@" +
|
||||
"sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||
)
|
||||
& docker image inspect $baseImage *> $null
|
||||
Assert-LastExitCode "pinned base image inspection"
|
||||
$runtimeVolume = "ndc-mission-core-m48t-upstream-parity-env"
|
||||
if (-not (& docker volume ls --quiet --filter "name=^$runtimeVolume$")) {
|
||||
throw "pinned RF-DETR dependency volume is unavailable"
|
||||
}
|
||||
|
||||
$modelRoot = Join-Path $runOutput "triton-models"
|
||||
$baselineModelDirectory = Join-Path $modelRoot "rf_detr_large"
|
||||
$baselineVersionDirectory = Join-Path $baselineModelDirectory "1"
|
||||
$nativeModelDirectory = Join-Path $modelRoot "rf_detr_large_native_kb4"
|
||||
$nativeVersionDirectory = Join-Path $nativeModelDirectory "1"
|
||||
$null = New-Item -ItemType Directory -Path $baselineVersionDirectory
|
||||
$null = New-Item -ItemType Directory -Path $nativeVersionDirectory
|
||||
Copy-Item -LiteralPath $baselineConfig -Destination (Join-Path $baselineModelDirectory "config.pbtxt")
|
||||
Copy-Item -LiteralPath $baselineEngine -Destination (Join-Path $baselineVersionDirectory "model.plan")
|
||||
Copy-Item -LiteralPath $nativeConfig -Destination (Join-Path $nativeModelDirectory "config.pbtxt")
|
||||
Copy-Item -LiteralPath $nativeEngine -Destination (Join-Path $nativeVersionDirectory "model.plan")
|
||||
if ((Get-Sha256 (Join-Path $baselineVersionDirectory "model.plan")) -cne $baselineEngineSha256) {
|
||||
throw "staged RF-DETR 704 TensorRT engine SHA-256 changed"
|
||||
}
|
||||
if ((Get-Sha256 (Join-Path $nativeVersionDirectory "model.plan")) -cne $nativeEngineSha256) {
|
||||
throw "staged native TensorRT engine SHA-256 changed"
|
||||
}
|
||||
|
||||
$tritonName = "ndc-mission-core-m48n-native-vs-704-triton"
|
||||
$runnerName = "ndc-mission-core-m48n-native-vs-704"
|
||||
foreach ($name in @($tritonName, $runnerName)) {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||
throw "M48N comparison container $name already exists"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
& docker create `
|
||||
--name $tritonName `
|
||||
--label "com.nodedc.product=mission-core" `
|
||||
--label "com.nodedc.stack=ndc-mission-core-compute" `
|
||||
--label "com.nodedc.role=bounded-rf-detr-native-vs-704" `
|
||||
--label "com.nodedc.managed-by=codex-bounded-experiment" `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--pids-limit 512 `
|
||||
--shm-size 1g `
|
||||
--gpus all `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=2g" `
|
||||
--health-cmd "curl --fail --silent http://127.0.0.1:8000/v2/health/ready" `
|
||||
--health-interval 5s `
|
||||
--health-timeout 3s `
|
||||
--health-start-period 30s `
|
||||
--health-retries 30 `
|
||||
-v ((Convert-ToDockerPath $modelRoot) + ":/models:ro") `
|
||||
$baseImage `
|
||||
tritonserver `
|
||||
--model-repository=/models `
|
||||
--model-control-mode=explicit `
|
||||
--load-model=rf_detr_large `
|
||||
--load-model=rf_detr_large_native_kb4 `
|
||||
--disable-auto-complete-config `
|
||||
--strict-readiness=true `
|
||||
--exit-on-error=true `
|
||||
--allow-http=true `
|
||||
--allow-grpc=false `
|
||||
--allow-metrics=false *> $null
|
||||
Assert-LastExitCode "M48N comparison Triton creation"
|
||||
& docker start $tritonName *> $null
|
||||
Assert-LastExitCode "M48N comparison Triton start"
|
||||
$ready = $false
|
||||
foreach ($attempt in 1..90) {
|
||||
Start-Sleep -Seconds 2
|
||||
$comparisonContainer = Get-Container $tritonName
|
||||
if (-not $comparisonContainer.State.Running) {
|
||||
& docker logs $tritonName
|
||||
throw "M48N comparison Triton stopped during startup"
|
||||
}
|
||||
if ($comparisonContainer.State.Health.Status -ceq "healthy") {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $ready) { throw "M48N comparison Triton did not become healthy" }
|
||||
|
||||
$runnerArguments = @(
|
||||
"run",
|
||||
"--name", $runnerName,
|
||||
"--label", "com.nodedc.product=mission-core",
|
||||
"--label", "com.nodedc.stack=ndc-mission-core-compute",
|
||||
"--label", "com.nodedc.role=bounded-rf-detr-native-vs-704-runner",
|
||||
"--label", "com.nodedc.managed-by=codex-bounded-experiment",
|
||||
"--network", ("container:{0}" -f $tritonName),
|
||||
"--read-only",
|
||||
"--security-opt", "no-new-privileges:true",
|
||||
"--cap-drop", "ALL",
|
||||
"--pids-limit", "512",
|
||||
"--shm-size", "1g",
|
||||
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
||||
"-e", "PYTHONPATH=/opt/opencv:/opt/parity",
|
||||
"-v", ($runtimeVolume + ":/opt/parity:ro"),
|
||||
"-v", ((Convert-ToDockerPath $opencvPackages) + ":/opt/opencv:ro"),
|
||||
"-v", ((Convert-ToDockerPath $release) + ":/release:ro"),
|
||||
"-v", ((Convert-ToDockerPath $video) + ":/input/video.mp4:ro"),
|
||||
"-v", ((Convert-ToDockerPath $mask) + ":/input/mask.png:ro"),
|
||||
"-v", ((Convert-ToDockerPath $runOutput) + ":/output:rw"),
|
||||
"--entrypoint", "python3",
|
||||
$baseImage,
|
||||
"/release/run_m48n_native_vs_704_worker.py",
|
||||
"--video", "/input/video.mp4",
|
||||
"--expected-video-sha256", $videoSha256,
|
||||
"--mask", "/input/mask.png",
|
||||
"--expected-mask-sha256", $maskSha256,
|
||||
"--baseline-triton-origin", "http://127.0.0.1:8000",
|
||||
"--native-triton-origin", "http://127.0.0.1:8000",
|
||||
"--baseline-engine-sha256", $baselineEngineSha256,
|
||||
"--native-engine-sha256", $nativeEngineSha256,
|
||||
"--output", "/output/result.json",
|
||||
"--frames", "/output/frames.jsonl"
|
||||
)
|
||||
if ($MaximumFrames -gt 0) {
|
||||
$runnerArguments += @("--maximum-frames", [string]$MaximumFrames)
|
||||
}
|
||||
& docker @runnerArguments
|
||||
Assert-LastExitCode "M48N native/704 RAVNOVES00 comparison"
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $runOutput "result.json") -PathType Leaf)) {
|
||||
throw "M48N native/704 comparison result was not written"
|
||||
}
|
||||
} finally {
|
||||
foreach ($name in @($runnerName, $tritonName)) {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||
& docker rm -f $name *> $null
|
||||
}
|
||||
}
|
||||
$historicalAfter = Get-Container "ndc-mission-core-triton"
|
||||
if (
|
||||
$historicalAfter.Id -cne $historicalId -or
|
||||
-not $historicalAfter.State.Running -or
|
||||
$historicalAfter.State.Health.Status -cne "healthy"
|
||||
) {
|
||||
throw "Canonical Triton changed during M48N native/704 comparison"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ("M48N_NATIVE_VS_704_RESULT={0}" -f (Join-Path $runOutput "result.json"))
|
||||
Write-Output "CANONICAL_TRITON_ACTION=none"
|
||||
Write-Output "SEMANTIC_AUTHORITY_CHANGED=false"
|
||||
@@ -0,0 +1,47 @@
|
||||
name: "rf_detr_large_native_kb4"
|
||||
platform: "tensorrt_plan"
|
||||
max_batch_size: 0
|
||||
|
||||
input [
|
||||
{
|
||||
name: "raw_kb4_bgr"
|
||||
data_type: TYPE_UINT8
|
||||
dims: [ 1, 600, 800, 3 ]
|
||||
}
|
||||
]
|
||||
|
||||
output [
|
||||
{
|
||||
name: "dets"
|
||||
data_type: TYPE_FP16
|
||||
dims: [ 1, 300, 4 ]
|
||||
},
|
||||
{
|
||||
name: "labels"
|
||||
data_type: TYPE_FP16
|
||||
dims: [ 1, 300, 91 ]
|
||||
}
|
||||
]
|
||||
|
||||
instance_group [
|
||||
{
|
||||
count: 1
|
||||
kind: KIND_GPU
|
||||
gpus: [ 0 ]
|
||||
}
|
||||
]
|
||||
|
||||
model_warmup [
|
||||
{
|
||||
name: "rf_detr_large_native_kb4_zero"
|
||||
batch_size: 0
|
||||
inputs: {
|
||||
key: "raw_kb4_bgr"
|
||||
value: {
|
||||
data_type: TYPE_UINT8
|
||||
dims: [ 1, 600, 800, 3 ]
|
||||
zero_data: true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fuse raw KB4 UINT8 preprocessing into the native RF-DETR ONNX graph."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
import onnx # type: ignore[import-not-found]
|
||||
from onnx import TensorProto, helper, numpy_helper
|
||||
from PIL import Image
|
||||
|
||||
SCHEMA_VERSION: Final = "missioncore.m48n-rf-detr-native-uint8-wrapper/v0"
|
||||
PROFILE_ID: Final = "rf-detr-large-coco-native-kb4-uint8-trt11-fp16/v0"
|
||||
RAW_INPUT_NAME: Final = "raw_kb4_bgr"
|
||||
SOURCE_HEIGHT: Final = 600
|
||||
SOURCE_WIDTH: Final = 800
|
||||
MODEL_HEIGHT: Final = 608
|
||||
MODEL_WIDTH: Final = 800
|
||||
FILL_VALUE: Final = 114
|
||||
MEANS: Final = (0.485, 0.456, 0.406)
|
||||
STDS: Final = (0.229, 0.224, 0.225)
|
||||
FALSE_AUTHORITY: Final = {
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", type=Path, required=True)
|
||||
parser.add_argument("--expected-input-sha256", required=True)
|
||||
parser.add_argument("--mask", type=Path, required=True)
|
||||
parser.add_argument("--expected-mask-sha256", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
source = arguments.input.resolve(strict=True)
|
||||
source_sha256 = sha256_path(source)
|
||||
if source_sha256 != arguments.expected_input_sha256:
|
||||
raise RuntimeError("FP16 core SHA-256 does not match the conversion manifest")
|
||||
mask_path = arguments.mask.resolve(strict=True)
|
||||
mask_sha256 = sha256_path(mask_path)
|
||||
if mask_sha256 != arguments.expected_mask_sha256:
|
||||
raise RuntimeError("valid-FOV mask SHA-256 changed")
|
||||
output = arguments.output.absolute()
|
||||
manifest = arguments.manifest.absolute()
|
||||
if output.exists() or manifest.exists():
|
||||
raise RuntimeError("wrapped ONNX output or manifest already exists")
|
||||
|
||||
started_utc_ns = time.time_ns()
|
||||
mask = np.asarray(Image.open(mask_path).convert("L")) > 0
|
||||
if mask.shape != (SOURCE_HEIGHT, SOURCE_WIDTH) or not np.any(mask):
|
||||
raise RuntimeError("valid-FOV mask geometry changed")
|
||||
graph = onnx.load(str(source))
|
||||
_attach_uint8_preprocessing(graph, np.asarray(mask, dtype=np.bool_))
|
||||
onnx.checker.check_model(graph)
|
||||
output.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
onnx.save(graph, str(output))
|
||||
verified = onnx.load(str(output), load_external_data=False)
|
||||
onnx.checker.check_model(verified)
|
||||
inputs = [_tensor_description(value) for value in verified.graph.input]
|
||||
outputs = [_tensor_description(value) for value in verified.graph.output]
|
||||
expected_input = [
|
||||
{
|
||||
"name": RAW_INPUT_NAME,
|
||||
"element_type": TensorProto.UINT8,
|
||||
"shape": [1, SOURCE_HEIGHT, SOURCE_WIDTH, 3],
|
||||
}
|
||||
]
|
||||
if inputs != expected_input:
|
||||
raise RuntimeError(f"wrapped native RF-DETR input boundary changed: {inputs}")
|
||||
if {item["name"]: item["element_type"] for item in outputs} != {
|
||||
"dets": TensorProto.FLOAT16,
|
||||
"labels": TensorProto.FLOAT16,
|
||||
}:
|
||||
raise RuntimeError(f"wrapped native RF-DETR outputs changed: {outputs}")
|
||||
operations = [node.op_type for node in verified.graph.node[:7]]
|
||||
if operations != ["Cast", "Mul", "Add", "Pad", "Gather", "Transpose", "Mul"]:
|
||||
raise RuntimeError(f"native preprocessing graph prefix changed: {operations}")
|
||||
if verified.graph.node[7].op_type != "Add":
|
||||
raise RuntimeError("native normalization bias node is missing")
|
||||
|
||||
document = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"profile_id": PROFILE_ID,
|
||||
"source_fp16_onnx_sha256": source_sha256,
|
||||
"valid_fov_mask_sha256": mask_sha256,
|
||||
"output_onnx_sha256": sha256_path(output),
|
||||
"output_size_bytes": output.stat().st_size,
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"preprocessing": {
|
||||
"execution_device": "TensorRT GPU graph",
|
||||
"source_raster_wh": [SOURCE_WIDTH, SOURCE_HEIGHT],
|
||||
"source_color": "BGR",
|
||||
"model_color": "RGB",
|
||||
"valid_fov_fill_value": FILL_VALUE,
|
||||
"padding_tblr": [0, 8, 0, 0],
|
||||
"model_canvas_wh": [MODEL_WIDTH, MODEL_HEIGHT],
|
||||
"normalization_mean": list(MEANS),
|
||||
"normalization_std": list(STDS),
|
||||
"resize": False,
|
||||
"rectification": False,
|
||||
"warp": False,
|
||||
"crop": False,
|
||||
},
|
||||
"transport": {
|
||||
"datatype": "UINT8",
|
||||
"layout": "NHWC",
|
||||
"bytes_per_frame": SOURCE_HEIGHT * SOURCE_WIDTH * 3,
|
||||
},
|
||||
"started_utc_ns": started_utc_ns,
|
||||
"completed_utc_ns": time.time_ns(),
|
||||
"completed": True,
|
||||
"authority": FALSE_AUTHORITY,
|
||||
}
|
||||
manifest.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
manifest.write_bytes(canonical_json(document) + b"\n")
|
||||
print(output)
|
||||
print(json.dumps(document, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def _attach_uint8_preprocessing(graph: Any, mask: np.ndarray[Any, np.dtype[np.bool_]]) -> None:
|
||||
old_input = next((item for item in graph.graph.input if item.name == "input"), None)
|
||||
if old_input is None:
|
||||
raise RuntimeError("FP16 core has no input tensor named 'input'")
|
||||
if _tensor_description(old_input) != {
|
||||
"name": "input",
|
||||
"element_type": TensorProto.FLOAT,
|
||||
"shape": [1, 3, MODEL_HEIGHT, MODEL_WIDTH],
|
||||
}:
|
||||
raise RuntimeError("FP16 core input contract changed")
|
||||
boundary_cast = next(
|
||||
(
|
||||
node
|
||||
for node in graph.graph.node
|
||||
if node.name == "missioncore_input_fp32_to_fp16"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
boundary_cast is None
|
||||
or boundary_cast.op_type != "Cast"
|
||||
or list(boundary_cast.input) != ["input"]
|
||||
or list(boundary_cast.output) != ["missioncore_input_fp16"]
|
||||
):
|
||||
raise RuntimeError("FP16 core boundary cast changed")
|
||||
|
||||
graph.graph.node.remove(boundary_cast)
|
||||
graph.graph.input.remove(old_input)
|
||||
graph.graph.input.insert(
|
||||
0,
|
||||
helper.make_tensor_value_info(
|
||||
RAW_INPUT_NAME,
|
||||
TensorProto.UINT8,
|
||||
[1, SOURCE_HEIGHT, SOURCE_WIDTH, 3],
|
||||
),
|
||||
)
|
||||
|
||||
valid = mask.astype(np.float16)[None, :, :, None]
|
||||
invalid_fill = ((~mask).astype(np.float16) * FILL_VALUE)[None, :, :, None]
|
||||
scale = np.asarray(
|
||||
[1.0 / (255.0 * value) for value in STDS],
|
||||
dtype=np.float16,
|
||||
).reshape(1, 3, 1, 1)
|
||||
bias = np.asarray(
|
||||
[-mean / std for mean, std in zip(MEANS, STDS, strict=True)],
|
||||
dtype=np.float16,
|
||||
).reshape(1, 3, 1, 1)
|
||||
initializers = (
|
||||
numpy_helper.from_array(valid, name="missioncore_valid_fov_fp16"),
|
||||
numpy_helper.from_array(invalid_fill, name="missioncore_invalid_fill_fp16"),
|
||||
numpy_helper.from_array(
|
||||
np.asarray((0, 0, 0, 0, 0, 8, 0, 0), dtype=np.int64),
|
||||
name="missioncore_bottom_pad",
|
||||
),
|
||||
numpy_helper.from_array(
|
||||
np.asarray(FILL_VALUE, dtype=np.float16),
|
||||
name="missioncore_pad_fill_fp16",
|
||||
),
|
||||
numpy_helper.from_array(
|
||||
np.asarray((2, 1, 0), dtype=np.int64),
|
||||
name="missioncore_bgr_to_rgb_indices",
|
||||
),
|
||||
numpy_helper.from_array(scale, name="missioncore_imagenet_scale_fp16"),
|
||||
numpy_helper.from_array(bias, name="missioncore_imagenet_bias_fp16"),
|
||||
)
|
||||
graph.graph.initializer.extend(initializers)
|
||||
|
||||
nodes = (
|
||||
helper.make_node(
|
||||
"Cast",
|
||||
inputs=[RAW_INPUT_NAME],
|
||||
outputs=["missioncore_raw_fp16"],
|
||||
name="missioncore_raw_uint8_to_fp16",
|
||||
to=TensorProto.FLOAT16,
|
||||
),
|
||||
helper.make_node(
|
||||
"Mul",
|
||||
inputs=["missioncore_raw_fp16", "missioncore_valid_fov_fp16"],
|
||||
outputs=["missioncore_valid_pixels_fp16"],
|
||||
name="missioncore_apply_valid_fov",
|
||||
),
|
||||
helper.make_node(
|
||||
"Add",
|
||||
inputs=["missioncore_valid_pixels_fp16", "missioncore_invalid_fill_fp16"],
|
||||
outputs=["missioncore_masked_bgr_fp16"],
|
||||
name="missioncore_fill_invalid_fov",
|
||||
),
|
||||
helper.make_node(
|
||||
"Pad",
|
||||
inputs=[
|
||||
"missioncore_masked_bgr_fp16",
|
||||
"missioncore_bottom_pad",
|
||||
"missioncore_pad_fill_fp16",
|
||||
],
|
||||
outputs=["missioncore_padded_bgr_fp16"],
|
||||
name="missioncore_pad_bottom_to_608",
|
||||
mode="constant",
|
||||
),
|
||||
helper.make_node(
|
||||
"Gather",
|
||||
inputs=["missioncore_padded_bgr_fp16", "missioncore_bgr_to_rgb_indices"],
|
||||
outputs=["missioncore_padded_rgb_fp16"],
|
||||
name="missioncore_bgr_to_rgb",
|
||||
axis=3,
|
||||
),
|
||||
helper.make_node(
|
||||
"Transpose",
|
||||
inputs=["missioncore_padded_rgb_fp16"],
|
||||
outputs=["missioncore_nchw_rgb_fp16"],
|
||||
name="missioncore_nhwc_to_nchw",
|
||||
perm=[0, 3, 1, 2],
|
||||
),
|
||||
helper.make_node(
|
||||
"Mul",
|
||||
inputs=["missioncore_nchw_rgb_fp16", "missioncore_imagenet_scale_fp16"],
|
||||
outputs=["missioncore_scaled_rgb_fp16"],
|
||||
name="missioncore_imagenet_scale",
|
||||
),
|
||||
helper.make_node(
|
||||
"Add",
|
||||
inputs=["missioncore_scaled_rgb_fp16", "missioncore_imagenet_bias_fp16"],
|
||||
outputs=["missioncore_input_fp16"],
|
||||
name="missioncore_imagenet_bias",
|
||||
),
|
||||
)
|
||||
for node in reversed(nodes):
|
||||
graph.graph.node.insert(0, node)
|
||||
|
||||
|
||||
def _tensor_description(value: Any) -> dict[str, object]:
|
||||
tensor = value.type.tensor_type
|
||||
return {
|
||||
"name": value.name,
|
||||
"element_type": tensor.elem_type,
|
||||
"shape": [_dimension_value(item) for item in tensor.shape.dim],
|
||||
}
|
||||
|
||||
|
||||
def _dimension_value(value: Any) -> int | str | None:
|
||||
if value.HasField("dim_value"):
|
||||
return int(value.dim_value)
|
||||
if value.HasField("dim_param"):
|
||||
return str(value.dim_param)
|
||||
return None
|
||||
|
||||
|
||||
def sha256_path(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 canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -14,6 +14,15 @@ from numpy.typing import NDArray
|
||||
|
||||
from .contracts import BoundingRegion2D, ObjectProposal2D
|
||||
from .providers import SourcePacket
|
||||
from .rf_detr_native_object_detector import (
|
||||
RF_DETR_NATIVE_CONFIG,
|
||||
RF_DETR_NATIVE_MODEL_ID,
|
||||
RF_DETR_NATIVE_MODEL_VERSION,
|
||||
NativeRfDetrConfig,
|
||||
NativeRfDetrInferenceBackend,
|
||||
postprocess_native_rf_detr,
|
||||
prepare_raw_kb4_rf_detr_native,
|
||||
)
|
||||
from .rf_detr_object_detector import (
|
||||
RF_DETR_CONFIG,
|
||||
RF_DETR_MODEL_ID,
|
||||
@@ -45,6 +54,15 @@ FROZEN_YOLOX_PREPROCESS_ID: Final = "raw-kb4-valid-fov-letterbox/v1"
|
||||
RF_DETR_SHADOW_PROVIDER_ID: Final = "triton-rf-detr-large-coco-risk-fp16-shadow/v0"
|
||||
RF_DETR_SHADOW_MODEL_ID: Final = f"{RF_DETR_MODEL_ID}:{RF_DETR_MODEL_VERSION}"
|
||||
RF_DETR_SHADOW_PREPROCESS_ID: Final = "raw-kb4-valid-fov-rgb-stretch-imagenet/v0"
|
||||
RF_DETR_NATIVE_SHADOW_PROVIDER_ID: Final = (
|
||||
"triton-rf-detr-large-coco-native-kb4-risk-fp16-shadow/v0"
|
||||
)
|
||||
RF_DETR_NATIVE_SHADOW_MODEL_ID: Final = (
|
||||
f"{RF_DETR_NATIVE_MODEL_ID}:{RF_DETR_NATIVE_MODEL_VERSION}"
|
||||
)
|
||||
RF_DETR_NATIVE_SHADOW_PREPROCESS_ID: Final = (
|
||||
"raw-kb4-uint8-fused-mask-rgb-pad8-imagenet-trt/v0"
|
||||
)
|
||||
|
||||
|
||||
class DetectorProviderError(RuntimeError):
|
||||
@@ -414,6 +432,164 @@ def proposals_from_rf_detr_detections(
|
||||
)
|
||||
|
||||
|
||||
class NativeRfDetrShadowDetectorProvider:
|
||||
"""Emit risk classes from one exact-raster native RF-DETR inference pass."""
|
||||
|
||||
provider_id: str = RF_DETR_NATIVE_SHADOW_PROVIDER_ID
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
mask: NDArray[np.bool_],
|
||||
backend: NativeRfDetrInferenceBackend,
|
||||
config: NativeRfDetrConfig = RF_DETR_NATIVE_CONFIG,
|
||||
clock_ns: Callable[[], int] = time.perf_counter_ns,
|
||||
timing_observer: DetectorTimingObserver | None = None,
|
||||
) -> None:
|
||||
if mask.shape != (600, 800) or mask.dtype != np.bool_ or not np.any(mask):
|
||||
raise DetectorProviderError("native RF-DETR valid-FOV mask is incompatible")
|
||||
self.mask = np.asarray(mask, dtype=np.bool_)
|
||||
self.backend = backend
|
||||
self.config = config
|
||||
self._clock_ns = clock_ns
|
||||
self.timing_observer = timing_observer
|
||||
self._lock = Lock()
|
||||
self._input_frames = 0
|
||||
self._completed_frames = 0
|
||||
self._failed_frames = 0
|
||||
self._zero_proposal_frames = 0
|
||||
self._proposal_count = 0
|
||||
self._rejected: Counter[str] = Counter()
|
||||
self._core_duration_ns = 0
|
||||
self._warmup_started = False
|
||||
self._warmup_snapshot: DetectorWarmupSnapshot | None = None
|
||||
|
||||
def warm_up(self) -> DetectorWarmupSnapshot:
|
||||
"""Prime raw transport and postprocessing before source admission."""
|
||||
|
||||
with self._lock:
|
||||
if self._warmup_snapshot is not None:
|
||||
return self._warmup_snapshot
|
||||
if self._warmup_started:
|
||||
raise DetectorProviderError("native RF-DETR warmup is already in progress")
|
||||
self._warmup_started = True
|
||||
started_ns = int(self._clock_ns())
|
||||
try:
|
||||
image = np.zeros(
|
||||
(self.config.source_height, self.config.source_width, 3),
|
||||
dtype=np.uint8,
|
||||
)
|
||||
tensor = prepare_raw_kb4_rf_detr_native(image, config=self.config)
|
||||
preprocessed_ns = int(self._clock_ns())
|
||||
output = self.backend.infer(tensor)
|
||||
inferred_ns = int(self._clock_ns())
|
||||
postprocess_native_rf_detr(output, self.mask, config=self.config)
|
||||
completed_ns = int(self._clock_ns())
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._warmup_started = False
|
||||
raise
|
||||
snapshot = DetectorWarmupSnapshot(
|
||||
completed=True,
|
||||
inference_passes=1,
|
||||
preprocess_duration_ns=max(0, preprocessed_ns - started_ns),
|
||||
inference_transport_duration_ns=max(0, inferred_ns - preprocessed_ns),
|
||||
postprocess_duration_ns=max(0, completed_ns - inferred_ns),
|
||||
total_duration_ns=max(0, completed_ns - started_ns),
|
||||
)
|
||||
with self._lock:
|
||||
self._warmup_snapshot = snapshot
|
||||
return snapshot
|
||||
|
||||
def detect(self, packet: SourcePacket) -> tuple[ObjectProposal2D, ...]:
|
||||
payload = packet.image_payload
|
||||
with self._lock:
|
||||
self._input_frames += 1
|
||||
started_ns = int(self._clock_ns())
|
||||
try:
|
||||
if not isinstance(payload, np.ndarray):
|
||||
raise DetectorProviderError(
|
||||
"native RF-DETR requires a decoded BGR image payload"
|
||||
)
|
||||
image = np.asarray(payload)
|
||||
if image.dtype != np.uint8:
|
||||
raise DetectorProviderError("decoded BGR image must be uint8")
|
||||
tensor = prepare_raw_kb4_rf_detr_native(image, config=self.config)
|
||||
preprocessed_ns = (
|
||||
int(self._clock_ns()) if self.timing_observer is not None else started_ns
|
||||
)
|
||||
output = self.backend.infer(tensor)
|
||||
inferred_ns = (
|
||||
int(self._clock_ns()) if self.timing_observer is not None else preprocessed_ns
|
||||
)
|
||||
postprocessed = postprocess_native_rf_detr(
|
||||
output,
|
||||
self.mask,
|
||||
config=self.config,
|
||||
)
|
||||
proposals = proposals_from_native_rf_detr_detections(
|
||||
packet,
|
||||
postprocessed.detections,
|
||||
)
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._failed_frames += 1
|
||||
self._core_duration_ns += max(0, int(self._clock_ns()) - started_ns)
|
||||
raise
|
||||
completed_ns = int(self._clock_ns())
|
||||
with self._lock:
|
||||
self._completed_frames += 1
|
||||
self._proposal_count += len(proposals)
|
||||
self._zero_proposal_frames += not proposals
|
||||
self._rejected.update(dict(postprocessed.rejected))
|
||||
self._core_duration_ns += max(0, completed_ns - started_ns)
|
||||
if self.timing_observer is not None:
|
||||
self.timing_observer(
|
||||
DetectorFrameTiming(
|
||||
sequence=packet.envelope.sequence,
|
||||
preprocess_duration_ns=max(0, preprocessed_ns - started_ns),
|
||||
inference_transport_duration_ns=max(0, inferred_ns - preprocessed_ns),
|
||||
postprocess_duration_ns=max(0, completed_ns - inferred_ns),
|
||||
total_duration_ns=max(0, completed_ns - started_ns),
|
||||
)
|
||||
)
|
||||
return proposals
|
||||
|
||||
def snapshot(self) -> DetectorProviderSnapshot:
|
||||
with self._lock:
|
||||
return DetectorProviderSnapshot(
|
||||
input_frames=self._input_frames,
|
||||
completed_frames=self._completed_frames,
|
||||
failed_frames=self._failed_frames,
|
||||
zero_proposal_frames=self._zero_proposal_frames,
|
||||
proposal_count=self._proposal_count,
|
||||
rejected=tuple(sorted(self._rejected.items())),
|
||||
core_duration_ns=self._core_duration_ns,
|
||||
)
|
||||
|
||||
|
||||
def proposals_from_native_rf_detr_detections(
|
||||
packet: SourcePacket,
|
||||
detections: tuple[RfDetrDetection, ...],
|
||||
) -> tuple[ObjectProposal2D, ...]:
|
||||
envelope = packet.envelope
|
||||
return tuple(
|
||||
ObjectProposal2D(
|
||||
proposal_id=f"proposal-{envelope.sequence}-{index}",
|
||||
source_id=envelope.source_id,
|
||||
frame_id=envelope.frame_id,
|
||||
region=BoundingRegion2D(*detection.bbox_xyxy),
|
||||
objectness=detection.score,
|
||||
provider_id=RF_DETR_NATIVE_SHADOW_PROVIDER_ID,
|
||||
model_id=RF_DETR_NATIVE_SHADOW_MODEL_ID,
|
||||
preprocess_id=RF_DETR_NATIVE_SHADOW_PREPROCESS_ID,
|
||||
semantic_hint=detection.label,
|
||||
provider_tracklet=None,
|
||||
)
|
||||
for index, detection in enumerate(detections)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALL_COCO_YOLOX_PROVIDER_ID",
|
||||
"FROZEN_YOLOX_MODEL_ID",
|
||||
@@ -422,6 +598,9 @@ __all__ = [
|
||||
"RF_DETR_SHADOW_MODEL_ID",
|
||||
"RF_DETR_SHADOW_PREPROCESS_ID",
|
||||
"RF_DETR_SHADOW_PROVIDER_ID",
|
||||
"RF_DETR_NATIVE_SHADOW_MODEL_ID",
|
||||
"RF_DETR_NATIVE_SHADOW_PREPROCESS_ID",
|
||||
"RF_DETR_NATIVE_SHADOW_PROVIDER_ID",
|
||||
"DetectorProviderError",
|
||||
"DetectorProviderSnapshot",
|
||||
"DetectorFrameTiming",
|
||||
@@ -429,7 +608,9 @@ __all__ = [
|
||||
"DetectorWarmupSnapshot",
|
||||
"AllCocoYoloxDetectorProvider",
|
||||
"FrozenYoloxDetectorProvider",
|
||||
"NativeRfDetrShadowDetectorProvider",
|
||||
"RfDetrShadowDetectorProvider",
|
||||
"proposals_from_detections",
|
||||
"proposals_from_native_rf_detr_detections",
|
||||
"proposals_from_rf_detr_detections",
|
||||
]
|
||||
|
||||
@@ -8,12 +8,15 @@ from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import Literal
|
||||
|
||||
from .baseline import load_m4_baseline
|
||||
from .detector import (
|
||||
RF_DETR_NATIVE_SHADOW_PROVIDER_ID,
|
||||
RF_DETR_SHADOW_PROVIDER_ID,
|
||||
DetectorTimingObserver,
|
||||
DetectorWarmupSnapshot,
|
||||
NativeRfDetrShadowDetectorProvider,
|
||||
RfDetrShadowDetectorProvider,
|
||||
)
|
||||
from .geometry import (
|
||||
@@ -41,6 +44,12 @@ from .recorded_source import (
|
||||
SourcePacingObserver,
|
||||
)
|
||||
from .reference_graph_runtime import ReferenceGraphRuntimePaths
|
||||
from .rf_detr_native_object_detector import (
|
||||
RF_DETR_NATIVE_ENGINE_SHA256,
|
||||
RF_DETR_NATIVE_MODEL_ID,
|
||||
RF_DETR_NATIVE_MODEL_VERSION,
|
||||
TritonNativeRfDetrHttpInferenceBackend,
|
||||
)
|
||||
from .rf_detr_object_detector import (
|
||||
RF_DETR_ENGINE_SHA256,
|
||||
RF_DETR_MODEL_ID,
|
||||
@@ -66,13 +75,18 @@ class M48sReferenceGraphRuntime:
|
||||
"""Own one RF-DETR shadow graph and its persistent inference transport."""
|
||||
|
||||
graph: ReferencePerceptionGraphV2
|
||||
inference_backend: TritonRfDetrHttpInferenceBackend
|
||||
inference_backend: (
|
||||
TritonRfDetrHttpInferenceBackend | TritonNativeRfDetrHttpInferenceBackend
|
||||
)
|
||||
source_prefetch: PrefetchedRecordedImageDecoder
|
||||
_preparation_stop_event: Event = field(default_factory=Event)
|
||||
|
||||
def warm_up_detector(self) -> DetectorWarmupSnapshot:
|
||||
detector = self.graph.detector
|
||||
if not isinstance(detector, RfDetrShadowDetectorProvider):
|
||||
if not isinstance(
|
||||
detector,
|
||||
(RfDetrShadowDetectorProvider, NativeRfDetrShadowDetectorProvider),
|
||||
):
|
||||
raise M48sReferenceGraphRuntimeError("RF-DETR runtime detector changed before warmup")
|
||||
return detector.warm_up()
|
||||
|
||||
@@ -125,7 +139,7 @@ def build_m48s_reference_graph_runtime(
|
||||
ProviderRole.THREAT: paths.threat_profile,
|
||||
}
|
||||
_validate_provider_digests(config, pinned_files)
|
||||
_validate_detector_profile(detector_profile)
|
||||
detector_variant = _validate_detector_profile(detector_profile)
|
||||
|
||||
load_m4_baseline(paths.baseline_profile)
|
||||
geometry_profile = load_geometry_profile(paths.geometry_profile)
|
||||
@@ -161,7 +175,22 @@ def build_m48s_reference_graph_runtime(
|
||||
)
|
||||
if maximum_frames is not None:
|
||||
source = _LimitedSource(source, maximum_frames)
|
||||
backend = TritonRfDetrHttpInferenceBackend(triton_origin)
|
||||
if detector_variant == "legacy-704":
|
||||
backend: (
|
||||
TritonRfDetrHttpInferenceBackend | TritonNativeRfDetrHttpInferenceBackend
|
||||
) = TritonRfDetrHttpInferenceBackend(triton_origin)
|
||||
detector = RfDetrShadowDetectorProvider(
|
||||
mask=load_valid_fov_mask(paths.valid_fov_mask),
|
||||
backend=backend,
|
||||
timing_observer=detector_timing_observer,
|
||||
)
|
||||
else:
|
||||
backend = TritonNativeRfDetrHttpInferenceBackend(triton_origin)
|
||||
detector = NativeRfDetrShadowDetectorProvider(
|
||||
mask=load_valid_fov_mask(paths.valid_fov_mask),
|
||||
backend=backend,
|
||||
timing_observer=detector_timing_observer,
|
||||
)
|
||||
try:
|
||||
store = RecordedGeometryStore(
|
||||
source_pack_path=paths.source_pack,
|
||||
@@ -175,11 +204,7 @@ def build_m48s_reference_graph_runtime(
|
||||
graph = ReferencePerceptionGraphV2(
|
||||
config=config,
|
||||
source=source,
|
||||
detector=RfDetrShadowDetectorProvider(
|
||||
mask=load_valid_fov_mask(paths.valid_fov_mask),
|
||||
backend=backend,
|
||||
timing_observer=detector_timing_observer,
|
||||
),
|
||||
detector=detector,
|
||||
geometry=Ravnoves00GeometryAssociationProvider(store=store),
|
||||
temporal=BoundedSpatialTemporalProvider(
|
||||
point_resolver=store,
|
||||
@@ -249,7 +274,7 @@ def _validate_provider_digests(
|
||||
raise M48sReferenceGraphRuntimeError(f"{role.value} provider profile digest changed")
|
||||
|
||||
|
||||
def _validate_detector_profile(path: Path) -> None:
|
||||
def _validate_detector_profile(path: Path) -> Literal["legacy-704", "native-kb4"]:
|
||||
try:
|
||||
document = json.loads(path.resolve(strict=True).read_text("utf-8"))
|
||||
model = document["model"]
|
||||
@@ -257,24 +282,46 @@ def _validate_detector_profile(path: Path) -> None:
|
||||
authority = document["authority"]
|
||||
except (OSError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise M48sReferenceGraphRuntimeError("RF-DETR profile is incomplete") from exc
|
||||
if (
|
||||
document.get("schema_version") != "missioncore.rf-detr-risk-shadow-profile/v0"
|
||||
or document.get("provider_id") != RF_DETR_SHADOW_PROVIDER_ID
|
||||
or model.get("model_id") != RF_DETR_MODEL_ID
|
||||
or model.get("model_version") != RF_DETR_MODEL_VERSION
|
||||
or model.get("worker_006_rtx4090_tensorrt_11_engine_sha256") != RF_DETR_ENGINE_SHA256
|
||||
or status.get("detector_load_gate_passed") is not True
|
||||
or status.get("production_accepted") is not False
|
||||
or any(
|
||||
authority.get(key) is not False
|
||||
for key in (
|
||||
"candidate_accepted",
|
||||
"commands_enabled",
|
||||
"actuation_allowed",
|
||||
"navigation_or_safety_accepted",
|
||||
)
|
||||
authority_false = not any(
|
||||
authority.get(key) is not False
|
||||
for key in (
|
||||
"candidate_accepted",
|
||||
"commands_enabled",
|
||||
"actuation_allowed",
|
||||
"navigation_or_safety_accepted",
|
||||
)
|
||||
):
|
||||
)
|
||||
legacy = (
|
||||
document.get("schema_version") == "missioncore.rf-detr-risk-shadow-profile/v0"
|
||||
and document.get("provider_id") == RF_DETR_SHADOW_PROVIDER_ID
|
||||
and model.get("model_id") == RF_DETR_MODEL_ID
|
||||
and model.get("model_version") == RF_DETR_MODEL_VERSION
|
||||
and model.get("worker_006_rtx4090_tensorrt_11_engine_sha256")
|
||||
== RF_DETR_ENGINE_SHA256
|
||||
and status.get("detector_load_gate_passed") is True
|
||||
and status.get("production_accepted") is False
|
||||
and authority_false
|
||||
)
|
||||
native = (
|
||||
document.get("schema_version")
|
||||
== "missioncore.rf-detr-native-risk-shadow-profile/v0"
|
||||
and document.get("provider_id") == RF_DETR_NATIVE_SHADOW_PROVIDER_ID
|
||||
and model.get("model_id") == RF_DETR_NATIVE_MODEL_ID
|
||||
and model.get("model_version") == RF_DETR_NATIVE_MODEL_VERSION
|
||||
and model.get("worker_006_rtx4090_tensorrt_11_engine_sha256")
|
||||
== RF_DETR_NATIVE_ENGINE_SHA256
|
||||
and status.get("native_tensor_parity_passed") is True
|
||||
and status.get("full_ravnoves00_runtime_gate_passed") is True
|
||||
and status.get("legacy_704_box_agreement_gate_passed") is False
|
||||
and status.get("integrated_world_state_gate_passed") is False
|
||||
and status.get("production_accepted") is False
|
||||
and authority_false
|
||||
)
|
||||
if legacy:
|
||||
return "legacy-704"
|
||||
if native:
|
||||
return "native-kb4"
|
||||
else:
|
||||
raise M48sReferenceGraphRuntimeError("RF-DETR shadow profile identity changed")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Native raw-KB4 RF-DETR-L TensorRT transport and postprocessing.
|
||||
|
||||
The TensorRT engine owns valid-FOV masking, BGR-to-RGB conversion, eight
|
||||
bottom padding rows and ImageNet normalization. The client sends the exact
|
||||
800x600 UINT8 KB4 raster and performs no geometric resampling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import math
|
||||
import urllib.parse
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, Protocol, cast
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from .rf_detr_object_detector import (
|
||||
COCO_SPARSE_TO_CONTIGUOUS,
|
||||
RISK_CLASS_IDS,
|
||||
RfDetrDetection,
|
||||
RfDetrPostprocessResult,
|
||||
RfDetrRawOutput,
|
||||
)
|
||||
from .yolox_object_detector import COCO_CLASSES, YOLOX_VALID_FOV_SHA256
|
||||
|
||||
RF_DETR_NATIVE_MODEL_ID: Final = "rf_detr_large_native_kb4"
|
||||
RF_DETR_NATIVE_MODEL_VERSION: Final = 1
|
||||
RF_DETR_NATIVE_CHECKPOINT_SHA256: Final = (
|
||||
"0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38"
|
||||
)
|
||||
RF_DETR_NATIVE_CORE_ONNX_SHA256: Final = (
|
||||
"62e549748a1d17646b90ad06d3ac8a1b79595b7e9270cac4564418f023079176"
|
||||
)
|
||||
RF_DETR_NATIVE_FP16_ONNX_SHA256: Final = (
|
||||
"00b29fa2ff3d5fca730ebf3b8c33b10e9d690cc97bbbbfa5563d6e0abf210999"
|
||||
)
|
||||
RF_DETR_NATIVE_WRAPPED_ONNX_SHA256: Final = (
|
||||
"acdd01623a00d100331473c0a99eab1e5adf33117cbab900c8ae078edd4aa346"
|
||||
)
|
||||
RF_DETR_NATIVE_ENGINE_SHA256: Final = (
|
||||
"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"
|
||||
)
|
||||
RF_DETR_NATIVE_VALID_FOV_SHA256: Final = YOLOX_VALID_FOV_SHA256
|
||||
|
||||
|
||||
class NativeRfDetrDetectorError(RuntimeError):
|
||||
"""The native RF-DETR profile, tensor or response is incompatible."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NativeRfDetrConfig:
|
||||
source_width: int = 800
|
||||
source_height: int = 600
|
||||
model_width: int = 800
|
||||
model_height: int = 608
|
||||
bottom_padding_rows: int = 8
|
||||
fill_value: int = 114
|
||||
minimum_score: float = 0.25
|
||||
target_class_ids: tuple[int, ...] = RISK_CLASS_IDS
|
||||
maximum_detections: int = 300
|
||||
minimum_box_area_pixels: float = 64.0
|
||||
maximum_box_area_fraction: float = 0.5
|
||||
minimum_valid_fov_fraction: float = 0.5
|
||||
require_center_inside_valid_fov: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.source_width,
|
||||
self.source_height,
|
||||
self.model_width,
|
||||
self.model_height,
|
||||
self.bottom_padding_rows,
|
||||
self.fill_value,
|
||||
self.minimum_score,
|
||||
self.target_class_ids,
|
||||
self.maximum_detections,
|
||||
self.minimum_box_area_pixels,
|
||||
self.maximum_box_area_fraction,
|
||||
self.minimum_valid_fov_fraction,
|
||||
self.require_center_inside_valid_fov,
|
||||
) != (
|
||||
800,
|
||||
600,
|
||||
800,
|
||||
608,
|
||||
8,
|
||||
114,
|
||||
0.25,
|
||||
RISK_CLASS_IDS,
|
||||
300,
|
||||
64.0,
|
||||
0.5,
|
||||
0.5,
|
||||
True,
|
||||
):
|
||||
raise NativeRfDetrDetectorError(
|
||||
"native RF-DETR shadow profile cannot be tuned in place"
|
||||
)
|
||||
|
||||
|
||||
RF_DETR_NATIVE_CONFIG: Final = NativeRfDetrConfig()
|
||||
|
||||
|
||||
class NativeRfDetrInferenceBackend(Protocol):
|
||||
def infer(self, tensor: NDArray[np.uint8]) -> RfDetrRawOutput: ...
|
||||
|
||||
|
||||
class TritonNativeRfDetrHttpInferenceBackend:
|
||||
"""Persistent Triton V2 HTTP transport for exact raw UINT8 KB4 frames."""
|
||||
|
||||
def __init__(self, endpoint: str, *, timeout_seconds: float = 60.0) -> None:
|
||||
parsed = urllib.parse.urlsplit(endpoint)
|
||||
if (
|
||||
parsed.scheme != "http"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise NativeRfDetrDetectorError(
|
||||
"Triton endpoint must be an explicit HTTP origin"
|
||||
)
|
||||
if not math.isfinite(timeout_seconds) or timeout_seconds <= 0:
|
||||
raise NativeRfDetrDetectorError("Triton timeout must be positive")
|
||||
self.path = (
|
||||
f"{parsed.path.rstrip('/')}/v2/models/{RF_DETR_NATIVE_MODEL_ID}"
|
||||
f"/versions/{RF_DETR_NATIVE_MODEL_VERSION}/infer"
|
||||
)
|
||||
self.connection = http.client.HTTPConnection(
|
||||
parsed.hostname,
|
||||
parsed.port or 80,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def infer(self, tensor: NDArray[np.uint8]) -> RfDetrRawOutput:
|
||||
contiguous = np.ascontiguousarray(tensor, dtype=np.uint8)
|
||||
if contiguous.shape != (1, 600, 800, 3):
|
||||
raise NativeRfDetrDetectorError(
|
||||
"Triton native RF-DETR input tensor is incompatible"
|
||||
)
|
||||
binary = contiguous.tobytes()
|
||||
header = {
|
||||
"inputs": [
|
||||
{
|
||||
"name": "raw_kb4_bgr",
|
||||
"shape": [1, 600, 800, 3],
|
||||
"datatype": "UINT8",
|
||||
"parameters": {"binary_data_size": len(binary)},
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "dets", "parameters": {"binary_data": True}},
|
||||
{"name": "labels", "parameters": {"binary_data": True}},
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(header, sort_keys=True, separators=(",", ":")).encode()
|
||||
self.connection.request(
|
||||
"POST",
|
||||
self.path,
|
||||
body=encoded + binary,
|
||||
headers={
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Inference-Header-Content-Length": str(len(encoded)),
|
||||
},
|
||||
)
|
||||
response = self.connection.getresponse()
|
||||
payload = response.read()
|
||||
if response.status != 200:
|
||||
raise NativeRfDetrDetectorError(
|
||||
f"Triton native RF-DETR inference failed with HTTP {response.status}"
|
||||
)
|
||||
header_value = response.getheader("Inference-Header-Content-Length")
|
||||
try:
|
||||
header_length = int(header_value or "")
|
||||
descriptor = json.loads(payload[:header_length])
|
||||
outputs = descriptor["outputs"]
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise NativeRfDetrDetectorError(
|
||||
"Triton native RF-DETR output descriptor is invalid"
|
||||
) from exc
|
||||
if not isinstance(outputs, list) or len(outputs) != 2:
|
||||
raise NativeRfDetrDetectorError(
|
||||
"Triton native RF-DETR output count changed"
|
||||
)
|
||||
offset = header_length
|
||||
arrays: dict[str, NDArray[np.float16]] = {}
|
||||
for output, expected_name, expected_shape in zip(
|
||||
outputs,
|
||||
("dets", "labels"),
|
||||
((1, 300, 4), (1, 300, 91)),
|
||||
strict=True,
|
||||
):
|
||||
try:
|
||||
name = output["name"]
|
||||
datatype = output["datatype"]
|
||||
shape = tuple(int(value) for value in output["shape"])
|
||||
byte_length = int(output["parameters"]["binary_data_size"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise NativeRfDetrDetectorError(
|
||||
"Triton native RF-DETR output descriptor is incomplete"
|
||||
) from exc
|
||||
expected_bytes = math.prod(expected_shape) * np.dtype("<f2").itemsize
|
||||
if (
|
||||
name != expected_name
|
||||
or datatype != "FP16"
|
||||
or shape != expected_shape
|
||||
or byte_length != expected_bytes
|
||||
or offset + byte_length > len(payload)
|
||||
):
|
||||
raise NativeRfDetrDetectorError(
|
||||
"Triton native RF-DETR output identity changed"
|
||||
)
|
||||
array = np.frombuffer(payload[offset : offset + byte_length], dtype="<f2")
|
||||
arrays[name] = np.asarray(array.reshape(shape), dtype=np.float16)
|
||||
offset += byte_length
|
||||
if offset != len(payload):
|
||||
raise NativeRfDetrDetectorError(
|
||||
"Triton native RF-DETR output byte length changed"
|
||||
)
|
||||
return RfDetrRawOutput(boxes=arrays["dets"], logits=arrays["labels"])
|
||||
|
||||
|
||||
def prepare_raw_kb4_rf_detr_native(
|
||||
image_bgr: NDArray[np.uint8],
|
||||
*,
|
||||
config: NativeRfDetrConfig = RF_DETR_NATIVE_CONFIG,
|
||||
) -> NDArray[np.uint8]:
|
||||
"""Expose the exact raw KB4 raster as UINT8 NHWC without image transforms."""
|
||||
|
||||
if image_bgr.shape != (config.source_height, config.source_width, 3):
|
||||
raise NativeRfDetrDetectorError("raw KB4 image raster changed")
|
||||
if image_bgr.dtype != np.uint8:
|
||||
raise NativeRfDetrDetectorError("raw KB4 image must be uint8")
|
||||
return np.ascontiguousarray(image_bgr[None], dtype=np.uint8)
|
||||
|
||||
|
||||
def postprocess_native_rf_detr(
|
||||
output: RfDetrRawOutput,
|
||||
mask: NDArray[np.bool_],
|
||||
*,
|
||||
config: NativeRfDetrConfig = RF_DETR_NATIVE_CONFIG,
|
||||
) -> RfDetrPostprocessResult:
|
||||
if output.boxes.shape != (1, 300, 4) or output.logits.shape != (1, 300, 91):
|
||||
raise NativeRfDetrDetectorError("native RF-DETR output shapes are incompatible")
|
||||
if output.boxes.dtype != np.float16 or output.logits.dtype != np.float16:
|
||||
raise NativeRfDetrDetectorError("native RF-DETR output types are incompatible")
|
||||
if not np.isfinite(output.boxes).all() or not np.isfinite(output.logits).all():
|
||||
raise NativeRfDetrDetectorError("native RF-DETR output contains non-finite values")
|
||||
if mask.shape != (config.source_height, config.source_width) or mask.dtype != np.bool_:
|
||||
raise NativeRfDetrDetectorError("valid-FOV mask is incompatible")
|
||||
logits = output.logits[0].astype(np.float32)
|
||||
probabilities = 1.0 / (1.0 + np.exp(-np.clip(logits, -80.0, 80.0)))
|
||||
flattened = probabilities.reshape(-1)
|
||||
topk = np.argsort(-flattened, kind="stable")[: config.maximum_detections]
|
||||
integral = np.pad(mask.astype(np.int64), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
|
||||
rejected: Counter[str] = Counter()
|
||||
result: list[RfDetrDetection] = []
|
||||
for flat_index in topk:
|
||||
score = float(flattened[flat_index])
|
||||
if score <= config.minimum_score:
|
||||
continue
|
||||
query_index = int(flat_index // output.logits.shape[2])
|
||||
sparse_class_id = int(flat_index % output.logits.shape[2])
|
||||
class_id = COCO_SPARSE_TO_CONTIGUOUS.get(sparse_class_id)
|
||||
if class_id is None:
|
||||
rejected["unmapped-class-slot"] += 1
|
||||
continue
|
||||
if class_id not in config.target_class_ids:
|
||||
rejected["non-risk-class"] += 1
|
||||
continue
|
||||
center_x, center_y, box_width, box_height = (
|
||||
float(value) for value in output.boxes[0, query_index].astype(np.float32)
|
||||
)
|
||||
box = np.asarray(
|
||||
(
|
||||
(center_x - box_width / 2.0) * config.model_width,
|
||||
(center_y - box_height / 2.0) * config.model_height,
|
||||
(center_x + box_width / 2.0) * config.model_width,
|
||||
(center_y + box_height / 2.0) * config.model_height,
|
||||
),
|
||||
dtype=np.float32,
|
||||
)
|
||||
box[[0, 2]] = np.clip(box[[0, 2]], 0, config.source_width)
|
||||
box[[1, 3]] = np.clip(box[[1, 3]], 0, config.source_height)
|
||||
fraction, center_inside, area = _valid_fraction(box, integral)
|
||||
if area < config.minimum_box_area_pixels:
|
||||
rejected["small-box"] += 1
|
||||
continue
|
||||
if area / (config.source_width * config.source_height) > (
|
||||
config.maximum_box_area_fraction
|
||||
):
|
||||
rejected["large-box"] += 1
|
||||
continue
|
||||
if fraction < config.minimum_valid_fov_fraction:
|
||||
rejected["outside-valid-fov"] += 1
|
||||
continue
|
||||
if config.require_center_inside_valid_fov and not center_inside:
|
||||
rejected["center-outside-valid-fov"] += 1
|
||||
continue
|
||||
result.append(
|
||||
RfDetrDetection(
|
||||
class_id=class_id,
|
||||
label=COCO_CLASSES[class_id],
|
||||
score=round(score, 9),
|
||||
bbox_xyxy=cast(
|
||||
tuple[float, float, float, float],
|
||||
tuple(round(float(value), 6) for value in box),
|
||||
),
|
||||
valid_fov_fraction=round(fraction, 6),
|
||||
)
|
||||
)
|
||||
result.sort(key=lambda item: (-item.score, item.class_id))
|
||||
return RfDetrPostprocessResult(tuple(result), tuple(sorted(rejected.items())))
|
||||
|
||||
|
||||
def _valid_fraction(
|
||||
box: NDArray[np.float32], integral: NDArray[np.int64]
|
||||
) -> tuple[float, bool, float]:
|
||||
height = integral.shape[0] - 1
|
||||
width = integral.shape[1] - 1
|
||||
x1 = int(np.clip(math.floor(float(box[0])), 0, width))
|
||||
y1 = int(np.clip(math.floor(float(box[1])), 0, height))
|
||||
x2 = int(np.clip(math.ceil(float(box[2])), 0, width))
|
||||
y2 = int(np.clip(math.ceil(float(box[3])), 0, height))
|
||||
area = float(max(0, x2 - x1) * max(0, y2 - y1))
|
||||
if area <= 0:
|
||||
return 0.0, False, 0.0
|
||||
inside = integral[y2, x2] - integral[y1, x2] - integral[y2, x1] + integral[y1, x1]
|
||||
center_x = int(np.clip(round((float(box[0]) + float(box[2])) / 2.0), 0, width - 1))
|
||||
center_y = int(np.clip(round((float(box[1]) + float(box[3])) / 2.0), 0, height - 1))
|
||||
center_inside = bool(
|
||||
integral[center_y + 1, center_x + 1]
|
||||
- integral[center_y, center_x + 1]
|
||||
- integral[center_y + 1, center_x]
|
||||
+ integral[center_y, center_x]
|
||||
)
|
||||
return float(inside) / area, center_inside, area
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RF_DETR_NATIVE_CHECKPOINT_SHA256",
|
||||
"RF_DETR_NATIVE_CONFIG",
|
||||
"RF_DETR_NATIVE_CORE_ONNX_SHA256",
|
||||
"RF_DETR_NATIVE_ENGINE_SHA256",
|
||||
"RF_DETR_NATIVE_FP16_ONNX_SHA256",
|
||||
"RF_DETR_NATIVE_MODEL_ID",
|
||||
"RF_DETR_NATIVE_MODEL_VERSION",
|
||||
"RF_DETR_NATIVE_VALID_FOV_SHA256",
|
||||
"RF_DETR_NATIVE_WRAPPED_ONNX_SHA256",
|
||||
"NativeRfDetrConfig",
|
||||
"NativeRfDetrDetectorError",
|
||||
"NativeRfDetrInferenceBackend",
|
||||
"TritonNativeRfDetrHttpInferenceBackend",
|
||||
"postprocess_native_rf_detr",
|
||||
"prepare_raw_kb4_rf_detr_native",
|
||||
]
|
||||
@@ -20,6 +20,8 @@ BUILD_REQUEST_SCHEMA: Final = "gaussian-pipeline.build-request/v1"
|
||||
JOB_SCHEMA: Final = "gaussian-pipeline.job/v1"
|
||||
RESULT_SCHEMA: Final = "gaussian-pipeline.build-result/v1"
|
||||
CAPABILITIES_SCHEMA: Final = "gaussian-pipeline.capabilities/v1"
|
||||
ARCHIVE_INGEST_REQUEST_SCHEMA: Final = "gaussian-pipeline.archive-ingest-request/v1"
|
||||
ARCHIVE_INGEST_SCHEMA: Final = "gaussian-pipeline.archive-ingest/v1"
|
||||
SAFE_UPLOAD_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
|
||||
SOURCE_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
|
||||
@@ -79,6 +81,24 @@ class GaussianSourceBundleUpload:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GaussianArchiveUpload:
|
||||
upload_id: str
|
||||
archive_name: str
|
||||
format: str
|
||||
sha256: str
|
||||
byte_length: int
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"upload_id": self.upload_id,
|
||||
"archive_name": self.archive_name,
|
||||
"format": self.format,
|
||||
"sha256": self.sha256,
|
||||
"byte_length": self.byte_length,
|
||||
}
|
||||
|
||||
|
||||
class GaussianPipelineGateway:
|
||||
"""TUS and JSON client with bounded responses and independent digest checks."""
|
||||
|
||||
@@ -124,6 +144,8 @@ class GaussianPipelineGateway:
|
||||
or document.get("api_version") != "gaussian-pipeline.api/v1"
|
||||
or document.get("upload_protocol") != "tus/1.0.0"
|
||||
or document.get("source_transport") != "tus-bundle/v1"
|
||||
or document.get("archive_transport") != "tus-archive/v1"
|
||||
or document.get("archive_formats") != ["zip", "rar", "7z"]
|
||||
):
|
||||
raise GaussianPipelineGatewayError("Gaussian provider capabilities do not match v1")
|
||||
_validate_runtime_provenance(document)
|
||||
@@ -196,6 +218,66 @@ class GaussianPipelineGateway:
|
||||
members=members,
|
||||
)
|
||||
|
||||
def upload_source_archive(self, archive_path: Path) -> GaussianArchiveUpload:
|
||||
source_candidate = archive_path.expanduser().absolute()
|
||||
if source_candidate.is_symlink() or not source_candidate.is_file():
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source archive must be one regular file"
|
||||
)
|
||||
source = source_candidate.resolve()
|
||||
archive_name = source.name
|
||||
suffix = source.suffix.lower()
|
||||
archive_formats = {".zip": "zip", ".rar": "rar", ".7z": "7z"}
|
||||
archive_format = archive_formats.get(suffix)
|
||||
if archive_format is None or "/" in archive_name or "\\" in archive_name:
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source archive must be zip, rar or 7z"
|
||||
)
|
||||
byte_length = source.stat().st_size
|
||||
if byte_length <= 0:
|
||||
raise GaussianPipelineIntegrityError("Gaussian source archive is empty")
|
||||
digest = _sha256(source)
|
||||
capabilities = self.capabilities()
|
||||
max_source_bytes = capabilities.get("max_source_bytes")
|
||||
if (
|
||||
not isinstance(max_source_bytes, int)
|
||||
or isinstance(max_source_bytes, bool)
|
||||
or byte_length > max_source_bytes
|
||||
):
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source archive exceeds provider byte admission"
|
||||
)
|
||||
upload_id = self._upload_file(
|
||||
source,
|
||||
{"archive_name": archive_name, "sha256": digest},
|
||||
byte_length,
|
||||
)
|
||||
return GaussianArchiveUpload(
|
||||
upload_id=upload_id,
|
||||
archive_name=archive_name,
|
||||
format=archive_format,
|
||||
sha256=digest,
|
||||
byte_length=byte_length,
|
||||
)
|
||||
|
||||
def normalize_archive(
|
||||
self,
|
||||
archive: GaussianArchiveUpload,
|
||||
) -> GaussianSourceBundleUpload:
|
||||
document = self._json(
|
||||
"POST",
|
||||
"/v1/ingests",
|
||||
document={
|
||||
"schema_version": ARCHIVE_INGEST_REQUEST_SCHEMA,
|
||||
"archive": archive.to_dict(),
|
||||
},
|
||||
)
|
||||
if document.get("schema_version") != ARCHIVE_INGEST_SCHEMA:
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest response does not match v1"
|
||||
)
|
||||
return _source_bundle_upload(document.get("source"))
|
||||
|
||||
def _upload_member(
|
||||
self,
|
||||
source: Path,
|
||||
@@ -204,9 +286,25 @@ class GaussianPipelineGateway:
|
||||
byte_length: int,
|
||||
) -> GaussianSourceMemberUpload:
|
||||
|
||||
metadata = _tus_metadata(
|
||||
{"logical_path": logical_path, "sha256": sha256}
|
||||
upload_id = self._upload_file(
|
||||
source,
|
||||
{"logical_path": logical_path, "sha256": sha256},
|
||||
byte_length,
|
||||
)
|
||||
return GaussianSourceMemberUpload(
|
||||
upload_id=upload_id,
|
||||
logical_path=logical_path,
|
||||
sha256=sha256,
|
||||
byte_length=byte_length,
|
||||
)
|
||||
|
||||
def _upload_file(
|
||||
self,
|
||||
source: Path,
|
||||
metadata_values: Mapping[str, str],
|
||||
byte_length: int,
|
||||
) -> str:
|
||||
metadata = _tus_metadata(metadata_values)
|
||||
try:
|
||||
response = self._client.post(
|
||||
"/v1/uploads",
|
||||
@@ -230,12 +328,7 @@ class GaussianPipelineGateway:
|
||||
if SAFE_UPLOAD_ID.fullmatch(upload_id) is None:
|
||||
raise GaussianPipelineGatewayError("Gaussian upload id is invalid")
|
||||
self._send_file(source, upload_url, byte_length)
|
||||
return GaussianSourceMemberUpload(
|
||||
upload_id=upload_id,
|
||||
logical_path=logical_path,
|
||||
sha256=sha256,
|
||||
byte_length=byte_length,
|
||||
)
|
||||
return upload_id
|
||||
|
||||
def submit_build(self, document: Mapping[str, object]) -> dict[str, Any]:
|
||||
if document.get("schema_version") != BUILD_REQUEST_SCHEMA:
|
||||
@@ -260,6 +353,14 @@ class GaussianPipelineGateway:
|
||||
_validate_runtime_provenance(result)
|
||||
return result
|
||||
|
||||
def delete_job(self, job_id: str) -> None:
|
||||
_safe_id(job_id, "job id")
|
||||
try:
|
||||
response = self._client.delete(f"/v1/jobs/{quote(job_id, safe='')}")
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise _unavailable("Gaussian job deletion failed", exc) from exc
|
||||
|
||||
def download_artifact(
|
||||
self,
|
||||
job_id: str,
|
||||
@@ -408,6 +509,35 @@ def configured_gaussian_pipeline_gateway() -> GaussianPipelineGateway | None:
|
||||
return GaussianPipelineGateway(endpoint, Path(token_file))
|
||||
|
||||
|
||||
def discover_gaussian_source_bundle(root_path: Path) -> tuple[str, str]:
|
||||
root_candidate = root_path.expanduser().absolute()
|
||||
if root_candidate.is_symlink() or not root_candidate.is_dir():
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source bundle root must be one regular directory"
|
||||
)
|
||||
root = root_candidate.resolve()
|
||||
descriptors: list[str] = []
|
||||
for candidate in root.rglob("*"):
|
||||
if candidate.is_symlink():
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source bundle contains a symlink"
|
||||
)
|
||||
if not candidate.is_file():
|
||||
continue
|
||||
suffix = candidate.suffix.lower()
|
||||
if suffix in {".lcc", ".lcc2"}:
|
||||
descriptors.append(candidate.relative_to(root).as_posix())
|
||||
descriptors.sort(key=lambda value: value.encode("utf-8"))
|
||||
if len(descriptors) != 1:
|
||||
raise GaussianPipelineIntegrityError(
|
||||
"Gaussian source folder must contain exactly one LCC or LCC2 descriptor"
|
||||
)
|
||||
entrypoint = descriptors[0]
|
||||
source_format = "lcc2" if entrypoint.lower().endswith(".lcc2") else "lcc"
|
||||
_discover_bundle_members(root, entrypoint, source_format)
|
||||
return entrypoint, source_format
|
||||
|
||||
|
||||
def _endpoint(value: str) -> str:
|
||||
parsed = urlparse(value)
|
||||
if (
|
||||
@@ -492,6 +622,101 @@ def _bundle_sha256(
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def _source_bundle_upload(value: object) -> GaussianSourceBundleUpload:
|
||||
if not isinstance(value, dict) or set(value) != {
|
||||
"format",
|
||||
"entrypoint",
|
||||
"bundle_sha256",
|
||||
"total_byte_length",
|
||||
"members",
|
||||
}:
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest source contract is invalid"
|
||||
)
|
||||
source_format = value.get("format")
|
||||
entrypoint = value.get("entrypoint")
|
||||
bundle_sha256 = value.get("bundle_sha256")
|
||||
total_byte_length = value.get("total_byte_length")
|
||||
raw_members = value.get("members")
|
||||
if (
|
||||
source_format not in {"lcc", "lcc2"}
|
||||
or not isinstance(entrypoint, str)
|
||||
or not entrypoint.lower().endswith(f".{source_format}")
|
||||
or not isinstance(bundle_sha256, str)
|
||||
or SHA256_PATTERN.fullmatch(bundle_sha256) is None
|
||||
or not isinstance(total_byte_length, int)
|
||||
or isinstance(total_byte_length, bool)
|
||||
or total_byte_length <= 0
|
||||
or not isinstance(raw_members, list)
|
||||
or not raw_members
|
||||
or len(raw_members) > 10_000
|
||||
):
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest source fields are invalid"
|
||||
)
|
||||
logical_entrypoint = _logical_path(entrypoint, "entrypoint")
|
||||
members: list[GaussianSourceMemberUpload] = []
|
||||
for raw_member in raw_members:
|
||||
if not isinstance(raw_member, dict) or set(raw_member) != {
|
||||
"upload_id",
|
||||
"logical_path",
|
||||
"sha256",
|
||||
"byte_length",
|
||||
}:
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest member contract is invalid"
|
||||
)
|
||||
upload_id = raw_member.get("upload_id")
|
||||
logical_path = raw_member.get("logical_path")
|
||||
sha256 = raw_member.get("sha256")
|
||||
byte_length = raw_member.get("byte_length")
|
||||
if (
|
||||
not isinstance(upload_id, str)
|
||||
or SAFE_UPLOAD_ID.fullmatch(upload_id) is None
|
||||
or not isinstance(logical_path, str)
|
||||
or not isinstance(sha256, str)
|
||||
or SHA256_PATTERN.fullmatch(sha256) is None
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length <= 0
|
||||
):
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest member fields are invalid"
|
||||
)
|
||||
members.append(
|
||||
GaussianSourceMemberUpload(
|
||||
upload_id=upload_id,
|
||||
logical_path=_logical_path(logical_path, "bundle member path"),
|
||||
sha256=sha256,
|
||||
byte_length=byte_length,
|
||||
)
|
||||
)
|
||||
ordered = tuple(sorted(members, key=lambda item: item.logical_path.encode("utf-8")))
|
||||
if len({member.logical_path for member in ordered}) != len(ordered):
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest contains duplicate members"
|
||||
)
|
||||
if not any(member.logical_path == logical_entrypoint for member in ordered):
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest omitted its entrypoint"
|
||||
)
|
||||
if sum(member.byte_length for member in ordered) != total_byte_length:
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest byte total is invalid"
|
||||
)
|
||||
if _bundle_sha256(source_format, logical_entrypoint, ordered) != bundle_sha256:
|
||||
raise GaussianPipelineGatewayError(
|
||||
"Gaussian archive ingest bundle digest is invalid"
|
||||
)
|
||||
return GaussianSourceBundleUpload(
|
||||
format=source_format,
|
||||
entrypoint=logical_entrypoint,
|
||||
bundle_sha256=bundle_sha256,
|
||||
total_byte_length=total_byte_length,
|
||||
members=ordered,
|
||||
)
|
||||
|
||||
|
||||
def _discover_bundle_members(root: Path, entrypoint: str, source_format: str) -> set[str]:
|
||||
descriptor = _bundle_member(root, entrypoint)
|
||||
if descriptor.stat().st_size > 16 * 1024 * 1024:
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Durable Mission Core catalog and orchestration for portable Gaussian worlds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
from urllib.parse import quote
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.simulation.gaussian_pipeline_gateway import (
|
||||
BUILD_REQUEST_SCHEMA,
|
||||
GaussianPipelineGateway,
|
||||
GaussianPipelineGatewayError,
|
||||
configured_gaussian_pipeline_gateway,
|
||||
discover_gaussian_source_bundle,
|
||||
)
|
||||
|
||||
PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1"
|
||||
WORLD_MANIFEST_SCHEMA: Final = "missioncore.simulation-world-manifest/v1"
|
||||
PROJECT_ID_PATTERN: Final = re.compile(r"^sim-[a-f0-9]{32}$")
|
||||
SOURCE_FILE_ID_PATTERN: Final = re.compile(r"^src-[0-9]{5}-[a-f0-9]{8}$")
|
||||
PROVIDER_JOB_ID_PATTERN: Final = re.compile(r"^gsp-[0-9]{14}-[a-f0-9]{8}$")
|
||||
MAX_SOURCE_FILES: Final = 10_000
|
||||
MAX_SOURCE_BYTES: Final = 16 * 1024 * 1024 * 1024
|
||||
MAX_UPLOAD_CHUNK_BYTES: Final = 16 * 1024 * 1024
|
||||
TERMINAL_STATES: Final = {"ready", "failed"}
|
||||
ACTIVE_STATES: Final = {"queued", "processing", "importing"}
|
||||
PROVIDER_JOB_STATES: Final = {
|
||||
"queued",
|
||||
"verifying_source",
|
||||
"inspecting",
|
||||
"building_preview",
|
||||
"building_streamed_sog",
|
||||
"building_collision",
|
||||
"ready",
|
||||
"failed",
|
||||
}
|
||||
PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60
|
||||
|
||||
|
||||
class SimulationProjectError(RuntimeError):
|
||||
"""The project request violates its durable catalog contract."""
|
||||
|
||||
|
||||
class SimulationProjectNotFoundError(SimulationProjectError):
|
||||
"""The requested project or source member does not exist."""
|
||||
|
||||
|
||||
class SimulationProjectConflictError(SimulationProjectError):
|
||||
"""The requested transition conflicts with current project state."""
|
||||
|
||||
|
||||
class SimulationProjectStore:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.root = data_dir.expanduser().resolve() / "simulation-worlds"
|
||||
self.projects_root = self.root / "projects"
|
||||
self.projects_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
scene_type: str,
|
||||
source_kind: str,
|
||||
files: list[dict[str, object]],
|
||||
) -> dict[str, Any]:
|
||||
display_name = _project_name(name)
|
||||
if scene_type not in {"interior", "outdoor", "object"}:
|
||||
raise SimulationProjectError("simulation scene type is invalid")
|
||||
if source_kind not in {"archive", "folder"}:
|
||||
raise SimulationProjectError("simulation source kind is invalid")
|
||||
if not files or len(files) > MAX_SOURCE_FILES:
|
||||
raise SimulationProjectError("simulation source file count is outside limits")
|
||||
normalized: list[dict[str, object]] = []
|
||||
logical_paths: set[str] = set()
|
||||
total_bytes = 0
|
||||
for index, raw in enumerate(files):
|
||||
if set(raw) != {"logical_path", "byte_length"}:
|
||||
raise SimulationProjectError("simulation source file contract is invalid")
|
||||
logical_path = _logical_path(raw.get("logical_path"))
|
||||
byte_length = raw.get("byte_length")
|
||||
if (
|
||||
not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length <= 0
|
||||
or byte_length > MAX_SOURCE_BYTES
|
||||
):
|
||||
raise SimulationProjectError("simulation source file size is invalid")
|
||||
if logical_path in logical_paths:
|
||||
raise SimulationProjectError("simulation source paths must be unique")
|
||||
logical_paths.add(logical_path)
|
||||
total_bytes += byte_length
|
||||
normalized.append({
|
||||
"file_id": f"src-{index:05d}-{uuid4().hex[:8]}",
|
||||
"logical_path": logical_path,
|
||||
"byte_length": byte_length,
|
||||
"uploaded_bytes": 0,
|
||||
"sha256": None,
|
||||
})
|
||||
if total_bytes > MAX_SOURCE_BYTES:
|
||||
raise SimulationProjectError("simulation source exceeds byte admission")
|
||||
if source_kind == "archive":
|
||||
if len(normalized) != 1 or "/" in str(normalized[0]["logical_path"]):
|
||||
raise SimulationProjectError("archive source must be one top-level file")
|
||||
if Path(str(normalized[0]["logical_path"])).suffix.lower() not in {
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
}:
|
||||
raise SimulationProjectError("archive source must be zip, rar or 7z")
|
||||
project_id = f"sim-{uuid4().hex}"
|
||||
now = utc_now_iso()
|
||||
document: dict[str, Any] = {
|
||||
"schema_version": PROJECT_SCHEMA,
|
||||
"project_id": project_id,
|
||||
"name": display_name,
|
||||
"scene_type": scene_type,
|
||||
"status": "uploading",
|
||||
"source": {
|
||||
"kind": source_kind,
|
||||
"total_byte_length": total_bytes,
|
||||
"uploaded_byte_length": 0,
|
||||
"files": normalized,
|
||||
"bundle_sha256": None,
|
||||
},
|
||||
"provider": {
|
||||
"provider_id": "gaussian-pipeline",
|
||||
"job_id": None,
|
||||
"state": None,
|
||||
"progress": None,
|
||||
"runtime": None,
|
||||
},
|
||||
"artifacts": [],
|
||||
"world_manifest": None,
|
||||
"error": None,
|
||||
"created_at_utc": now,
|
||||
"updated_at_utc": now,
|
||||
}
|
||||
with self._lock:
|
||||
project_root = self._project_root(project_id)
|
||||
project_root.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||
(project_root / "source").mkdir(mode=0o700)
|
||||
(project_root / "artifacts").mkdir(mode=0o700)
|
||||
self._write(document)
|
||||
return document
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
projects: list[dict[str, Any]] = []
|
||||
for entry in self.projects_root.iterdir():
|
||||
if not entry.is_dir() or PROJECT_ID_PATTERN.fullmatch(entry.name) is None:
|
||||
continue
|
||||
projects.append(self._read(entry.name))
|
||||
projects.sort(
|
||||
key=lambda item: (str(item["created_at_utc"]), str(item["project_id"])),
|
||||
reverse=True,
|
||||
)
|
||||
return projects
|
||||
|
||||
def get(self, project_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return self._read(project_id)
|
||||
|
||||
def update_metadata(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
name: str,
|
||||
scene_type: str,
|
||||
) -> dict[str, Any]:
|
||||
display_name = _project_name(name)
|
||||
if scene_type not in {"interior", "outdoor", "object"}:
|
||||
raise SimulationProjectError("simulation scene type is invalid")
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
document["name"] = display_name
|
||||
document["scene_type"] = scene_type
|
||||
document["updated_at_utc"] = utc_now_iso()
|
||||
self._write(document)
|
||||
return document
|
||||
|
||||
def upload_state(self, project_id: str, file_id: str) -> tuple[int, int]:
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
source_file = _source_file(document, file_id)
|
||||
return int(source_file["uploaded_bytes"]), int(source_file["byte_length"])
|
||||
|
||||
def append_upload(
|
||||
self,
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
*,
|
||||
offset: int,
|
||||
payload: bytes,
|
||||
) -> dict[str, Any]:
|
||||
if not payload or len(payload) > MAX_UPLOAD_CHUNK_BYTES:
|
||||
raise SimulationProjectError("simulation upload chunk is outside limits")
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
if document["status"] != "uploading":
|
||||
raise SimulationProjectConflictError("simulation source upload is closed")
|
||||
source_file = _source_file(document, file_id)
|
||||
uploaded = int(source_file["uploaded_bytes"])
|
||||
byte_length = int(source_file["byte_length"])
|
||||
if offset != uploaded:
|
||||
raise SimulationProjectConflictError("simulation upload offset does not match")
|
||||
if uploaded + len(payload) > byte_length:
|
||||
raise SimulationProjectError("simulation upload exceeds declared file size")
|
||||
target = self._source_path(project_id, str(source_file["logical_path"]))
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
mode = "xb" if uploaded == 0 else "r+b"
|
||||
with target.open(mode) as stream:
|
||||
if uploaded:
|
||||
stream.seek(uploaded)
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
uploaded += len(payload)
|
||||
source_file["uploaded_bytes"] = uploaded
|
||||
if uploaded == byte_length:
|
||||
source_file["sha256"] = _sha256(target)
|
||||
document["source"]["uploaded_byte_length"] = sum(
|
||||
int(item["uploaded_bytes"]) for item in document["source"]["files"]
|
||||
)
|
||||
document["updated_at_utc"] = utc_now_iso()
|
||||
self._write(document)
|
||||
return document
|
||||
|
||||
def begin_build(self, project_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
if document["status"] != "uploading":
|
||||
raise SimulationProjectConflictError("simulation project cannot start a build")
|
||||
if any(
|
||||
int(item["uploaded_bytes"]) != int(item["byte_length"])
|
||||
for item in document["source"]["files"]
|
||||
):
|
||||
raise SimulationProjectConflictError("simulation source upload is incomplete")
|
||||
document["status"] = "queued"
|
||||
document["error"] = None
|
||||
document["updated_at_utc"] = utc_now_iso()
|
||||
self._write(document)
|
||||
return document
|
||||
|
||||
def update_processing(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
status: str,
|
||||
provider_job_id: str | None = None,
|
||||
provider_state: str | None = None,
|
||||
progress: object = None,
|
||||
bundle_sha256: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if status not in {"queued", "processing", "importing"}:
|
||||
raise SimulationProjectError("simulation processing status is invalid")
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
document["status"] = status
|
||||
if provider_job_id is not None:
|
||||
if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None:
|
||||
raise SimulationProjectError("simulation provider job id is invalid")
|
||||
document["provider"]["job_id"] = provider_job_id
|
||||
if provider_state is not None:
|
||||
document["provider"]["state"] = provider_state
|
||||
if progress is not None:
|
||||
document["provider"]["progress"] = progress
|
||||
if bundle_sha256 is not None:
|
||||
document["source"]["bundle_sha256"] = bundle_sha256
|
||||
document["updated_at_utc"] = utc_now_iso()
|
||||
self._write(document)
|
||||
return document
|
||||
|
||||
def complete(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
result: dict[str, Any],
|
||||
artifacts: list[dict[str, Any]],
|
||||
world_manifest: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
document["status"] = "ready"
|
||||
document["provider"]["state"] = "ready"
|
||||
document["provider"]["runtime"] = result.get("runtime")
|
||||
document["artifacts"] = artifacts
|
||||
document["world_manifest"] = world_manifest
|
||||
document["error"] = None
|
||||
document["updated_at_utc"] = utc_now_iso()
|
||||
self._write(document)
|
||||
return document
|
||||
|
||||
def fail(self, project_id: str, message: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
document["status"] = "failed"
|
||||
document["error"] = message[:2048]
|
||||
document["updated_at_utc"] = utc_now_iso()
|
||||
self._write(document)
|
||||
return document
|
||||
|
||||
def artifact_path(self, project_id: str, logical_path: str) -> tuple[Path, dict[str, Any]]:
|
||||
safe = _logical_path(logical_path)
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
descriptor = next(
|
||||
(item for item in document["artifacts"] if item.get("logical_path") == safe),
|
||||
None,
|
||||
)
|
||||
if descriptor is None:
|
||||
raise SimulationProjectNotFoundError("simulation artifact is unavailable")
|
||||
target = self._artifact_path(project_id, safe)
|
||||
if not target.is_file() or target.is_symlink():
|
||||
raise SimulationProjectNotFoundError("simulation artifact is unavailable")
|
||||
return target, descriptor
|
||||
|
||||
def delete(self, project_id: str) -> None:
|
||||
with self._lock:
|
||||
document = self._read(project_id)
|
||||
if document["status"] not in TERMINAL_STATES and document["status"] != "uploading":
|
||||
raise SimulationProjectConflictError(
|
||||
"active simulation project cannot be deleted"
|
||||
)
|
||||
shutil.rmtree(self._project_root(project_id))
|
||||
|
||||
def source_root(self, project_id: str) -> Path:
|
||||
self.get(project_id)
|
||||
return self._project_root(project_id) / "source"
|
||||
|
||||
def artifacts_root(self, project_id: str) -> Path:
|
||||
self.get(project_id)
|
||||
return self._project_root(project_id) / "artifacts"
|
||||
|
||||
def _read(self, project_id: str) -> dict[str, Any]:
|
||||
project_root = self._project_root(project_id)
|
||||
try:
|
||||
document = json.loads((project_root / "project.json").read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise SimulationProjectNotFoundError("simulation project is unavailable") from exc
|
||||
if (
|
||||
not isinstance(document, dict)
|
||||
or document.get("schema_version") != PROJECT_SCHEMA
|
||||
or document.get("project_id") != project_id
|
||||
):
|
||||
raise SimulationProjectError("persisted simulation project identity is invalid")
|
||||
return document
|
||||
|
||||
def _write(self, document: dict[str, Any]) -> None:
|
||||
project_id = str(document["project_id"])
|
||||
destination = self._project_root(project_id) / "project.json"
|
||||
temporary = destination.with_name(f".project-{uuid4().hex}.tmp")
|
||||
with temporary.open("x", encoding="utf-8") as stream:
|
||||
json.dump(document, stream, ensure_ascii=False, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(destination)
|
||||
|
||||
def _project_root(self, project_id: str) -> Path:
|
||||
if PROJECT_ID_PATTERN.fullmatch(project_id) is None:
|
||||
raise SimulationProjectNotFoundError("simulation project is unavailable")
|
||||
return self.projects_root / project_id
|
||||
|
||||
def _source_path(self, project_id: str, logical_path: str) -> Path:
|
||||
return _confined_path(self._project_root(project_id) / "source", logical_path)
|
||||
|
||||
def _artifact_path(self, project_id: str, logical_path: str) -> Path:
|
||||
return _confined_path(self._project_root(project_id) / "artifacts", logical_path)
|
||||
|
||||
|
||||
class SimulationProjectService:
|
||||
def __init__(
|
||||
self,
|
||||
store: SimulationProjectStore,
|
||||
provider_factory=configured_gaussian_pipeline_gateway,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.provider_factory = provider_factory
|
||||
|
||||
def recover_pending(self) -> int:
|
||||
pending = [
|
||||
project for project in self.store.list()
|
||||
if project.get("status") in ACTIVE_STATES
|
||||
]
|
||||
for project in pending:
|
||||
threading.Thread(
|
||||
target=self.process,
|
||||
args=(str(project["project_id"]),),
|
||||
name=f"simulation-recovery-{str(project['project_id'])[-8:]}",
|
||||
daemon=True,
|
||||
).start()
|
||||
return len(pending)
|
||||
|
||||
def process(self, project_id: str) -> None:
|
||||
provider: GaussianPipelineGateway | None = None
|
||||
try:
|
||||
project = self.store.get(project_id)
|
||||
provider = self.provider_factory()
|
||||
if provider is None:
|
||||
raise SimulationProjectError("Gaussian Pipeline не настроен.")
|
||||
provider.capabilities()
|
||||
existing_job_id = project["provider"].get("job_id")
|
||||
if isinstance(existing_job_id, str):
|
||||
job_id = existing_job_id
|
||||
else:
|
||||
if project["status"] != "queued":
|
||||
raise SimulationProjectError(
|
||||
"Активная Gaussian-сборка потеряла provider job id после перезапуска."
|
||||
)
|
||||
source_root = self.store.source_root(project_id)
|
||||
if project["source"]["kind"] == "archive":
|
||||
source_file = project["source"]["files"][0]
|
||||
archive = provider.upload_source_archive(
|
||||
_confined_path(source_root, str(source_file["logical_path"]))
|
||||
)
|
||||
source = provider.normalize_archive(archive)
|
||||
else:
|
||||
entrypoint, source_format = discover_gaussian_source_bundle(source_root)
|
||||
source = provider.upload_source_bundle(
|
||||
source_root,
|
||||
entrypoint=entrypoint,
|
||||
source_format=source_format,
|
||||
)
|
||||
request = {
|
||||
"schema_version": BUILD_REQUEST_SCHEMA,
|
||||
"idempotency_key": f"missioncore-{project_id}",
|
||||
"source": source.to_dict(),
|
||||
"outputs": {
|
||||
"preview_sog": True,
|
||||
"streamed_sog": True,
|
||||
"collision": False,
|
||||
},
|
||||
"preview_lod": "coarsest",
|
||||
"collision_profile": None,
|
||||
}
|
||||
submitted = provider.submit_build(request)
|
||||
job_id = submitted.get("job_id")
|
||||
if not isinstance(job_id, str):
|
||||
raise SimulationProjectError("Gaussian Pipeline не вернул job id.")
|
||||
self.store.update_processing(
|
||||
project_id,
|
||||
status="processing",
|
||||
provider_job_id=job_id,
|
||||
provider_state=str(submitted.get("state") or "queued"),
|
||||
progress=submitted.get("progress"),
|
||||
bundle_sha256=source.bundle_sha256,
|
||||
)
|
||||
deadline = time.monotonic() + PROVIDER_POLL_TIMEOUT_SECONDS
|
||||
while True:
|
||||
if time.monotonic() >= deadline:
|
||||
raise SimulationProjectError(
|
||||
"Gaussian Pipeline превысил лимит ожидания сборки."
|
||||
)
|
||||
job = provider.get_job(job_id)
|
||||
state = job.get("state")
|
||||
if not isinstance(state, str) or state not in PROVIDER_JOB_STATES:
|
||||
raise SimulationProjectError(
|
||||
"Gaussian Pipeline вернул неизвестное состояние сборки."
|
||||
)
|
||||
self.store.update_processing(
|
||||
project_id,
|
||||
status="processing",
|
||||
provider_state=str(state or "unknown"),
|
||||
progress=job.get("progress"),
|
||||
)
|
||||
if state == "failed":
|
||||
error = job.get("error")
|
||||
message = error.get("message") if isinstance(error, dict) else None
|
||||
raise SimulationProjectError(
|
||||
str(message or "Gaussian Pipeline завершил сборку с ошибкой.")
|
||||
)
|
||||
if state == "ready":
|
||||
break
|
||||
time.sleep(2.0)
|
||||
self.store.update_processing(
|
||||
project_id,
|
||||
status="importing",
|
||||
provider_state="ready",
|
||||
)
|
||||
result = provider.get_result(job_id)
|
||||
artifacts = _artifact_descriptors(result.get("artifacts"))
|
||||
artifacts_root = self.store.artifacts_root(project_id)
|
||||
for descriptor in artifacts:
|
||||
provider.download_artifact(
|
||||
job_id,
|
||||
descriptor,
|
||||
_confined_path(artifacts_root, str(descriptor["logical_path"])),
|
||||
)
|
||||
world_manifest = _world_manifest(project_id, artifacts)
|
||||
self.store.complete(
|
||||
project_id,
|
||||
result=result,
|
||||
artifacts=artifacts,
|
||||
world_manifest=world_manifest,
|
||||
)
|
||||
except (GaussianPipelineGatewayError, SimulationProjectError, OSError) as exc:
|
||||
with suppress(SimulationProjectError):
|
||||
self.store.fail(project_id, str(exc))
|
||||
finally:
|
||||
if provider is not None:
|
||||
provider.close()
|
||||
|
||||
def delete(self, project_id: str) -> None:
|
||||
project = self.store.get(project_id)
|
||||
job_id = project["provider"].get("job_id")
|
||||
if isinstance(job_id, str):
|
||||
provider = self.provider_factory()
|
||||
if provider is None:
|
||||
raise SimulationProjectConflictError(
|
||||
"Gaussian Pipeline недоступен для удаления серверных артефактов."
|
||||
)
|
||||
try:
|
||||
provider.delete_job(job_id)
|
||||
finally:
|
||||
provider.close()
|
||||
self.store.delete(project_id)
|
||||
|
||||
|
||||
def _artifact_descriptors(value: object) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise SimulationProjectError("Gaussian Pipeline не вернул артефакты сцены.")
|
||||
descriptors: list[dict[str, Any]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise SimulationProjectError("Gaussian artifact descriptor is invalid")
|
||||
logical_path = _logical_path(item.get("logical_path"))
|
||||
sha256 = item.get("sha256")
|
||||
byte_length = item.get("byte_length")
|
||||
media_type = item.get("media_type")
|
||||
role = item.get("role")
|
||||
if (
|
||||
not isinstance(sha256, str)
|
||||
or re.fullmatch(r"[a-f0-9]{64}", sha256) is None
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length < 0
|
||||
or not isinstance(media_type, str)
|
||||
or not isinstance(role, str)
|
||||
):
|
||||
raise SimulationProjectError("Gaussian artifact fields are invalid")
|
||||
descriptors.append({
|
||||
"role": role,
|
||||
"logical_path": logical_path,
|
||||
"media_type": media_type,
|
||||
"sha256": sha256,
|
||||
"byte_length": byte_length,
|
||||
})
|
||||
return descriptors
|
||||
|
||||
|
||||
def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
def url_for(role: str) -> str | None:
|
||||
descriptor = next((item for item in artifacts if item["role"] == role), None)
|
||||
if descriptor is None:
|
||||
return None
|
||||
encoded = "/".join(
|
||||
quote(part, safe="")
|
||||
for part in str(descriptor["logical_path"]).split("/")
|
||||
)
|
||||
return f"/api/v1/simulation-worlds/projects/{project_id}/artifacts/{encoded}"
|
||||
|
||||
return {
|
||||
"schema_version": WORLD_MANIFEST_SCHEMA,
|
||||
"project_id": project_id,
|
||||
"visual": {
|
||||
"preview_sog_url": url_for("preview"),
|
||||
"streamed_sog_url": url_for("stream-manifest"),
|
||||
},
|
||||
"collision": {
|
||||
"mesh_url": url_for("collision-mesh"),
|
||||
"available": url_for("collision-mesh") is not None,
|
||||
},
|
||||
"transforms": {
|
||||
"world_from_visual": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
"world_from_collision": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _project_name(value: str) -> str:
|
||||
normalized = " ".join(value.split())
|
||||
if not 1 <= len(normalized) <= 120:
|
||||
raise SimulationProjectError("simulation project name is invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def _logical_path(value: object) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > 1024:
|
||||
raise SimulationProjectError("simulation logical path is invalid")
|
||||
if value.startswith("/") or "\\" in value or "\x00" in value:
|
||||
raise SimulationProjectError("simulation logical path is unsafe")
|
||||
parts = value.split("/")
|
||||
if any(part in {"", ".", ".."} for part in parts):
|
||||
raise SimulationProjectError("simulation logical path is unsafe")
|
||||
return value
|
||||
|
||||
|
||||
def _source_file(document: dict[str, Any], file_id: str) -> dict[str, Any]:
|
||||
if SOURCE_FILE_ID_PATTERN.fullmatch(file_id) is None:
|
||||
raise SimulationProjectNotFoundError("simulation source file is unavailable")
|
||||
source_file = next(
|
||||
(item for item in document["source"]["files"] if item.get("file_id") == file_id),
|
||||
None,
|
||||
)
|
||||
if source_file is None:
|
||||
raise SimulationProjectNotFoundError("simulation source file is unavailable")
|
||||
return source_file
|
||||
|
||||
|
||||
def _confined_path(root: Path, logical_path: str) -> Path:
|
||||
safe = _logical_path(logical_path)
|
||||
candidate = root.joinpath(*safe.split("/")).resolve()
|
||||
resolved_root = root.resolve()
|
||||
if not candidate.is_relative_to(resolved_root):
|
||||
raise SimulationProjectError("simulation path escaped its project root")
|
||||
return candidate
|
||||
|
||||
|
||||
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()
|
||||
@@ -48,6 +48,7 @@ from k1link.sessions import (
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.simulation.projects import SimulationProjectService, SimulationProjectStore
|
||||
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
||||
from k1link.web.artifact_health_api import build_artifact_health_router
|
||||
from k1link.web.compute_contour_api import build_compute_contour_router
|
||||
@@ -153,6 +154,7 @@ from k1link.web.runtime_readiness import (
|
||||
)
|
||||
from k1link.web.session_api import build_session_router
|
||||
from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router
|
||||
from k1link.web.simulation_projects_api import build_simulation_projects_router
|
||||
from k1link.web.system_telemetry_api import build_system_telemetry_router
|
||||
from k1link.web.viewer_diagnostics_api import build_viewer_diagnostics_router
|
||||
|
||||
@@ -201,6 +203,8 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
session_store = SessionStore(REPOSITORY_ROOT)
|
||||
simulation_project_store = SimulationProjectStore(session_store.data_dir)
|
||||
simulation_project_service = SimulationProjectService(simulation_project_store)
|
||||
session_artifact_gateway = configured_artifact_gateway(session_store.data_dir)
|
||||
lidar_local_surface_read_service = K1LocalSurfaceReadService(
|
||||
session_store.data_dir / "lidar-read-cache"
|
||||
@@ -478,6 +482,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
configure_scanner_diagnostics(session_store.data_dir / "logs")
|
||||
session_recording_preparation_manager.start()
|
||||
simulation_project_service.recover_pending()
|
||||
# Recovery is intentionally a one-shot startup phase. The archive
|
||||
# helper owns a cross-process lease, while ordinary catalog requests
|
||||
# only perform discovery and therefore never touch a live writer.
|
||||
@@ -1310,6 +1315,12 @@ app.include_router(
|
||||
)
|
||||
)
|
||||
app.include_router(build_simulation_world_provider_router())
|
||||
app.include_router(
|
||||
build_simulation_projects_router(
|
||||
store=simulation_project_store,
|
||||
service=simulation_project_service,
|
||||
)
|
||||
)
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
app.include_router(
|
||||
build_viewer_diagnostics_router(
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Mission Core API for durable Gaussian simulation projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.simulation.gaussian_pipeline_gateway import GaussianPipelineGatewayError
|
||||
from k1link.simulation.projects import (
|
||||
MAX_UPLOAD_CHUNK_BYTES,
|
||||
PROJECT_SCHEMA,
|
||||
SimulationProjectConflictError,
|
||||
SimulationProjectError,
|
||||
SimulationProjectNotFoundError,
|
||||
SimulationProjectService,
|
||||
SimulationProjectStore,
|
||||
)
|
||||
|
||||
|
||||
class SimulationSourceFileCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
logical_path: str = Field(min_length=1, max_length=1024)
|
||||
byte_length: int = Field(gt=0)
|
||||
|
||||
|
||||
class SimulationProjectCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project-create/v1"]
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
scene_type: Literal["interior", "outdoor", "object"]
|
||||
source_kind: Literal["archive", "folder"]
|
||||
files: list[SimulationSourceFileCreate] = Field(min_length=1, max_length=10_000)
|
||||
|
||||
|
||||
class SimulationProjectUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project-update/v1"]
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
scene_type: Literal["interior", "outdoor", "object"]
|
||||
|
||||
|
||||
class SimulationProjectDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project/v1"] = PROJECT_SCHEMA
|
||||
project_id: str
|
||||
name: str
|
||||
scene_type: Literal["interior", "outdoor", "object"]
|
||||
status: Literal["uploading", "queued", "processing", "importing", "ready", "failed"]
|
||||
source: dict[str, Any]
|
||||
provider: dict[str, Any]
|
||||
artifacts: list[dict[str, Any]]
|
||||
world_manifest: dict[str, Any] | None
|
||||
error: str | None
|
||||
created_at_utc: str
|
||||
updated_at_utc: str
|
||||
|
||||
|
||||
class SimulationProjectPage(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal["missioncore.simulation-project-page/v1"] = (
|
||||
"missioncore.simulation-project-page/v1"
|
||||
)
|
||||
projects: list[SimulationProjectDocument]
|
||||
|
||||
|
||||
def build_simulation_projects_router(
|
||||
*,
|
||||
store: SimulationProjectStore,
|
||||
service: SimulationProjectService,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/simulation-worlds", tags=["simulation-worlds"])
|
||||
|
||||
@router.get("/projects", response_model=SimulationProjectPage)
|
||||
def list_projects() -> SimulationProjectPage:
|
||||
return SimulationProjectPage(projects=store.list())
|
||||
|
||||
@router.post("/projects", response_model=SimulationProjectDocument, status_code=201)
|
||||
def create_project(request: SimulationProjectCreate) -> dict[str, Any]:
|
||||
try:
|
||||
return store.create(
|
||||
name=request.name,
|
||||
scene_type=request.scene_type,
|
||||
source_kind=request.source_kind,
|
||||
files=[item.model_dump() for item in request.files],
|
||||
)
|
||||
except SimulationProjectError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=SimulationProjectDocument)
|
||||
def get_project(project_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return store.get(project_id)
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
|
||||
@router.patch("/projects/{project_id}", response_model=SimulationProjectDocument)
|
||||
def update_project(
|
||||
project_id: str,
|
||||
request: SimulationProjectUpdate,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return store.update_metadata(
|
||||
project_id,
|
||||
name=request.name,
|
||||
scene_type=request.scene_type,
|
||||
)
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
except SimulationProjectError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.head("/projects/{project_id}/source/{file_id}")
|
||||
def source_upload_state(project_id: str, file_id: str) -> Response:
|
||||
try:
|
||||
offset, byte_length = store.upload_state(project_id, file_id)
|
||||
return Response(status_code=204, headers={
|
||||
"Upload-Offset": str(offset),
|
||||
"Upload-Length": str(byte_length),
|
||||
"Cache-Control": "no-store",
|
||||
})
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc
|
||||
|
||||
@router.patch("/projects/{project_id}/source/{file_id}")
|
||||
async def append_source_upload(
|
||||
project_id: str,
|
||||
file_id: str,
|
||||
request: Request,
|
||||
upload_offset: str | None = Header(default=None, alias="Upload-Offset"),
|
||||
) -> Response:
|
||||
if upload_offset is None or not upload_offset.isdigit():
|
||||
raise HTTPException(status_code=400, detail="Некорректное смещение загрузки.")
|
||||
content_length = request.headers.get("content-length")
|
||||
if (
|
||||
content_length is None
|
||||
or not content_length.isdigit()
|
||||
or not 0 < int(content_length) <= MAX_UPLOAD_CHUNK_BYTES
|
||||
):
|
||||
raise HTTPException(status_code=413, detail="Некорректный размер блока загрузки.")
|
||||
payload = await request.body()
|
||||
if len(payload) != int(content_length):
|
||||
raise HTTPException(status_code=400, detail="Неполный блок загрузки.")
|
||||
try:
|
||||
project = store.append_upload(
|
||||
project_id,
|
||||
file_id,
|
||||
offset=int(upload_offset),
|
||||
payload=payload,
|
||||
)
|
||||
source_file = next(
|
||||
item for item in project["source"]["files"] if item["file_id"] == file_id
|
||||
)
|
||||
return Response(status_code=204, headers={
|
||||
"Upload-Offset": str(source_file["uploaded_bytes"]),
|
||||
"Upload-Length": str(source_file["byte_length"]),
|
||||
"Cache-Control": "no-store",
|
||||
})
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc
|
||||
except SimulationProjectConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except SimulationProjectError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@router.post("/projects/{project_id}/build", response_model=SimulationProjectDocument)
|
||||
def build_project(
|
||||
project_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
project = store.begin_build(project_id)
|
||||
background_tasks.add_task(service.process, project_id)
|
||||
return project
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
except SimulationProjectConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@router.delete("/projects/{project_id}", status_code=204)
|
||||
def delete_project(project_id: str) -> Response:
|
||||
try:
|
||||
service.delete(project_id)
|
||||
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc
|
||||
except SimulationProjectConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except GaussianPipelineGatewayError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
@router.get("/projects/{project_id}/artifacts/{logical_path:path}")
|
||||
def get_artifact(project_id: str, logical_path: str) -> FileResponse:
|
||||
try:
|
||||
filename, descriptor = store.artifact_path(project_id, logical_path)
|
||||
return FileResponse(
|
||||
filename,
|
||||
media_type=str(descriptor["media_type"]),
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": f'"sha256-{descriptor["sha256"]}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
except SimulationProjectNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Артефакт сцены не найден.") from exc
|
||||
|
||||
return router
|
||||
@@ -38,6 +38,8 @@ def test_gateway_uploads_lcc_bundle_with_tus_and_reads_provider_contract(tmp_pat
|
||||
"api_version": "gaussian-pipeline.api/v1",
|
||||
"upload_protocol": "tus/1.0.0",
|
||||
"source_transport": "tus-bundle/v1",
|
||||
"archive_transport": "tus-archive/v1",
|
||||
"archive_formats": ["zip", "rar", "7z"],
|
||||
"provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"},
|
||||
"runtime": {
|
||||
"source_revision": "a" * 40,
|
||||
@@ -114,6 +116,92 @@ def test_gateway_uploads_lcc_bundle_with_tus_and_reads_provider_contract(tmp_pat
|
||||
assert descriptor.total_byte_length == sum(member.byte_length for member in descriptor.members)
|
||||
|
||||
|
||||
def test_gateway_uploads_and_normalizes_archive_with_tus(tmp_path: Path) -> None:
|
||||
archive_bytes = b"portable-archive"
|
||||
archive_sha = hashlib.sha256(archive_bytes).hexdigest()
|
||||
descriptor_bytes = b'{}'
|
||||
descriptor_sha = hashlib.sha256(descriptor_bytes).hexdigest()
|
||||
source_identity = {
|
||||
"format": "lcc2",
|
||||
"entrypoint": "result/scene.lcc2",
|
||||
"members": [{
|
||||
"logical_path": "result/scene.lcc2",
|
||||
"sha256": descriptor_sha,
|
||||
"byte_length": len(descriptor_bytes),
|
||||
}],
|
||||
}
|
||||
bundle_sha = hashlib.sha256(
|
||||
json.dumps(source_identity, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
received = bytearray()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET" and request.url.path == "/v1/capabilities":
|
||||
return httpx.Response(200, json={
|
||||
"schema_version": "gaussian-pipeline.capabilities/v1",
|
||||
"service": "ndc-gaussian-pipeline",
|
||||
"api_version": "gaussian-pipeline.api/v1",
|
||||
"upload_protocol": "tus/1.0.0",
|
||||
"source_transport": "tus-bundle/v1",
|
||||
"archive_transport": "tus-archive/v1",
|
||||
"archive_formats": ["zip", "rar", "7z"],
|
||||
"runtime": {
|
||||
"source_revision": "a" * 40,
|
||||
"image_digest": f"sha256:{'b' * 64}",
|
||||
},
|
||||
"max_source_bytes": 1024,
|
||||
"max_source_files": 10_000,
|
||||
})
|
||||
if request.method == "POST" and request.url.path == "/v1/uploads":
|
||||
metadata = request.headers["upload-metadata"]
|
||||
assert "archive_name " in metadata
|
||||
assert "logical_path " not in metadata
|
||||
return httpx.Response(201, headers={"Location": "/v1/uploads/archive-001"})
|
||||
if request.method == "HEAD" and request.url.path.endswith("archive-001"):
|
||||
return httpx.Response(200, headers={"Upload-Offset": str(len(received))})
|
||||
if request.method == "PATCH" and request.url.path.endswith("archive-001"):
|
||||
received.extend(request.content)
|
||||
return httpx.Response(204, headers={"Upload-Offset": str(len(received))})
|
||||
if request.method == "POST" and request.url.path == "/v1/ingests":
|
||||
submitted = json.loads(request.content)
|
||||
assert submitted["archive"] == {
|
||||
"upload_id": "archive-001",
|
||||
"archive_name": "source.rar",
|
||||
"format": "rar",
|
||||
"sha256": archive_sha,
|
||||
"byte_length": len(archive_bytes),
|
||||
}
|
||||
return httpx.Response(201, json={
|
||||
"schema_version": "gaussian-pipeline.archive-ingest/v1",
|
||||
"ingest_id": "gsi-20260826000000-deadbeef",
|
||||
"source": {
|
||||
**source_identity,
|
||||
"bundle_sha256": bundle_sha,
|
||||
"total_byte_length": len(descriptor_bytes),
|
||||
"members": [{
|
||||
"upload_id": "ing-member-001",
|
||||
**source_identity["members"][0],
|
||||
}],
|
||||
},
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
archive = tmp_path / "source.rar"
|
||||
archive.write_bytes(archive_bytes)
|
||||
with GaussianPipelineGateway(
|
||||
"http://gaussian.test",
|
||||
_token_file(tmp_path),
|
||||
chunk_bytes=5,
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as gateway:
|
||||
uploaded = gateway.upload_source_archive(archive)
|
||||
source = gateway.normalize_archive(uploaded)
|
||||
|
||||
assert bytes(received) == archive_bytes
|
||||
assert source.entrypoint == "result/scene.lcc2"
|
||||
assert source.bundle_sha256 == bundle_sha
|
||||
|
||||
|
||||
def test_gateway_rejects_incomplete_lcc_bundle(tmp_path: Path) -> None:
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
@@ -171,6 +259,8 @@ def test_gateway_rejects_upload_location_outside_provider(tmp_path: Path) -> Non
|
||||
"api_version": "gaussian-pipeline.api/v1",
|
||||
"upload_protocol": "tus/1.0.0",
|
||||
"source_transport": "tus-bundle/v1",
|
||||
"archive_transport": "tus-archive/v1",
|
||||
"archive_formats": ["zip", "rar", "7z"],
|
||||
"runtime": {
|
||||
"source_revision": "a" * 40,
|
||||
"image_digest": f"sha256:{'b' * 64}",
|
||||
@@ -295,6 +385,8 @@ def test_gateway_rejects_capabilities_without_runtime_provenance(tmp_path: Path)
|
||||
"api_version": "gaussian-pipeline.api/v1",
|
||||
"upload_protocol": "tus/1.0.0",
|
||||
"source_transport": "tus-bundle/v1",
|
||||
"archive_transport": "tus-archive/v1",
|
||||
"archive_formats": ["zip", "rar", "7z"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.perception.detector import RF_DETR_SHADOW_PROVIDER_ID
|
||||
from k1link.perception.detector import (
|
||||
RF_DETR_NATIVE_SHADOW_PROVIDER_ID,
|
||||
RF_DETR_SHADOW_PROVIDER_ID,
|
||||
)
|
||||
from k1link.perception.m48s_advisory import (
|
||||
AdvisoryFamily,
|
||||
advisory_policy_matrix,
|
||||
@@ -17,6 +20,10 @@ GRAPH_CONFIG = (
|
||||
REPOSITORY_ROOT
|
||||
/ "config/perception/m48s-rf-detr-reference-graph-shadow-v0.json"
|
||||
)
|
||||
NATIVE_GRAPH_CONFIG = (
|
||||
REPOSITORY_ROOT
|
||||
/ "config/perception/m48n-rf-detr-native-reference-graph-shadow-v0.json"
|
||||
)
|
||||
|
||||
|
||||
def test_m48s_reference_graph_replaces_only_the_detector_pin() -> None:
|
||||
@@ -64,6 +71,51 @@ def test_m48s_reference_graph_pins_every_profile_digest() -> None:
|
||||
assert pins[role].sha256 == hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def test_m48n_native_reference_graph_replaces_only_the_detector_pin() -> None:
|
||||
native = ReferencePerceptionGraphConfigV2.from_dict(
|
||||
json.loads(NATIVE_GRAPH_CONFIG.read_text("utf-8"))
|
||||
)
|
||||
legacy = ReferencePerceptionGraphConfigV2.from_dict(
|
||||
json.loads(GRAPH_CONFIG.read_text("utf-8"))
|
||||
)
|
||||
native_pins = {item.role: item for item in native.providers}
|
||||
legacy_pins = {item.role: item for item in legacy.providers}
|
||||
|
||||
assert native.graph_id == legacy.graph_id == "reference-perception-graph/v2"
|
||||
assert native.source_profile_id == legacy.source_profile_id
|
||||
assert native.queues == legacy.queues
|
||||
assert native.authority == legacy.authority
|
||||
assert (
|
||||
native_pins[ProviderRole.DETECTOR].provider_id
|
||||
== RF_DETR_NATIVE_SHADOW_PROVIDER_ID
|
||||
)
|
||||
assert all(
|
||||
native_pins[role] == legacy_pins[role]
|
||||
for role in ProviderRole
|
||||
if role is not ProviderRole.DETECTOR
|
||||
)
|
||||
|
||||
|
||||
def test_m48n_native_reference_graph_pins_every_profile_digest() -> None:
|
||||
config = ReferencePerceptionGraphConfigV2.from_dict(
|
||||
json.loads(NATIVE_GRAPH_CONFIG.read_text("utf-8"))
|
||||
)
|
||||
paths = {
|
||||
ProviderRole.SOURCE: "m4-recorded-realtime-baseline-v1.json",
|
||||
ProviderRole.DETECTOR: "rf-detr-large-native-kb4-risk-shadow-v0.json",
|
||||
ProviderRole.GEOMETRY: "m4-geometry-association-v1.json",
|
||||
ProviderRole.TEMPORAL: "m4-temporal-motion-v1.json",
|
||||
ProviderRole.MOTION: "m4-temporal-motion-v1.json",
|
||||
ProviderRole.ROLLING: "m4-rolling-local-map-v1.json",
|
||||
ProviderRole.THREAT: "m4-replay-threat-v3.json",
|
||||
}
|
||||
pins = {item.role: item for item in config.providers}
|
||||
|
||||
for role, name in paths.items():
|
||||
payload = (REPOSITORY_ROOT / "config/perception" / name).read_bytes()
|
||||
assert pins[role].sha256 == hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def test_m48s_advisory_policy_is_bounded_distinct_and_commandless() -> None:
|
||||
matrix = advisory_policy_matrix()
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
ClockBasis,
|
||||
ModalityOutcome,
|
||||
ModalityStatus,
|
||||
SourceEnvelope,
|
||||
TimestampBundle,
|
||||
)
|
||||
from k1link.perception.detector import (
|
||||
RF_DETR_NATIVE_SHADOW_MODEL_ID,
|
||||
RF_DETR_NATIVE_SHADOW_PREPROCESS_ID,
|
||||
RF_DETR_NATIVE_SHADOW_PROVIDER_ID,
|
||||
DetectorFrameTiming,
|
||||
NativeRfDetrShadowDetectorProvider,
|
||||
)
|
||||
from k1link.perception.m48s_reference_graph_runtime import _validate_detector_profile
|
||||
from k1link.perception.providers import SourcePacket
|
||||
from k1link.perception.rf_detr_native_object_detector import (
|
||||
RF_DETR_NATIVE_CONFIG,
|
||||
RF_DETR_NATIVE_ENGINE_SHA256,
|
||||
NativeRfDetrConfig,
|
||||
NativeRfDetrDetectorError,
|
||||
TritonNativeRfDetrHttpInferenceBackend,
|
||||
postprocess_native_rf_detr,
|
||||
prepare_raw_kb4_rf_detr_native,
|
||||
)
|
||||
from k1link.perception.rf_detr_object_detector import RfDetrRawOutput
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _status() -> ModalityStatus:
|
||||
return ModalityStatus(True, ModalityOutcome.AVAILABLE, "test-available")
|
||||
|
||||
|
||||
def _packet(sequence: int, image: object) -> SourcePacket:
|
||||
return SourcePacket(
|
||||
envelope=SourceEnvelope(
|
||||
source_id="RAVNOVES00",
|
||||
session_id="20260720T065719Z_viewer_live",
|
||||
frame_id=f"frame-{sequence:06d}",
|
||||
sequence=sequence,
|
||||
timestamps=TimestampBundle(
|
||||
utc_ns=1_000 + sequence,
|
||||
monotonic_ns=2_000 + sequence,
|
||||
source_ns=3_000 + sequence,
|
||||
clock_basis=ClockBasis.RECORDED_HOST,
|
||||
),
|
||||
source_age_ns=0,
|
||||
binding_reason="test-recorded-source",
|
||||
calibration_id="camera-1-kb4-test",
|
||||
representation_id="registered-map-increment-v1",
|
||||
image=_status(),
|
||||
registered_point_increment=_status(),
|
||||
pose=_status(),
|
||||
),
|
||||
image_payload=image,
|
||||
registered_point_increment_payload=("points", sequence),
|
||||
pose_payload=("pose", sequence),
|
||||
)
|
||||
|
||||
|
||||
def _output() -> RfDetrRawOutput:
|
||||
boxes = np.zeros((1, 300, 4), dtype=np.float16)
|
||||
logits = np.full((1, 300, 91), -20.0, dtype=np.float16)
|
||||
boxes[0, 0] = (0.5, 0.5, 0.25, 0.25)
|
||||
logits[0, 0, 18] = np.float16(math.log(3.0)) # dog, score 0.75
|
||||
boxes[0, 1] = (0.25, 0.25, 0.1, 0.2)
|
||||
logits[0, 1, 1] = np.float16(math.log(4.0)) # person, score 0.80
|
||||
boxes[0, 2] = (0.75, 0.25, 0.1, 0.2)
|
||||
logits[0, 2, 62] = np.float16(math.log(9.0)) # chair, non-risk
|
||||
return RfDetrRawOutput(boxes=boxes, logits=logits)
|
||||
|
||||
|
||||
class _Backend:
|
||||
def __init__(self, output: RfDetrRawOutput) -> None:
|
||||
self.output = output
|
||||
self.calls = 0
|
||||
|
||||
def infer(self, tensor: NDArray[np.uint8]) -> RfDetrRawOutput:
|
||||
assert tensor.shape == (1, 600, 800, 3)
|
||||
assert tensor.dtype == np.uint8
|
||||
assert tensor.flags.c_contiguous
|
||||
self.calls += 1
|
||||
return self.output
|
||||
|
||||
|
||||
def test_native_prepare_preserves_every_raw_pixel_without_geometric_transform() -> None:
|
||||
raster = np.arange(600 * 800 * 3, dtype=np.uint8).reshape(600, 800, 3)
|
||||
non_contiguous = raster[:, ::-1]
|
||||
|
||||
tensor = prepare_raw_kb4_rf_detr_native(non_contiguous)
|
||||
|
||||
assert tensor.shape == (1, 600, 800, 3)
|
||||
assert tensor.dtype == np.uint8
|
||||
assert tensor.flags.c_contiguous
|
||||
assert tensor.nbytes == 1_440_000
|
||||
np.testing.assert_array_equal(tensor[0], non_contiguous)
|
||||
|
||||
|
||||
def test_native_postprocess_uses_608_model_canvas_then_clips_to_raw_raster() -> None:
|
||||
result = postprocess_native_rf_detr(
|
||||
_output(),
|
||||
np.ones((600, 800), dtype=np.bool_),
|
||||
)
|
||||
|
||||
assert tuple(item.label for item in result.detections) == ("person", "dog")
|
||||
assert result.detections[0].score == pytest.approx(0.8, abs=0.001)
|
||||
assert result.detections[1].score == pytest.approx(0.75, abs=0.001)
|
||||
assert result.detections[1].bbox_xyxy == pytest.approx(
|
||||
(300.0, 228.0, 500.0, 380.0),
|
||||
abs=0.03,
|
||||
)
|
||||
assert dict(result.rejected) == {"non-risk-class": 1}
|
||||
|
||||
|
||||
def test_native_shadow_provider_uses_one_raw_pass_and_preserves_identity() -> None:
|
||||
backend = _Backend(_output())
|
||||
provider = NativeRfDetrShadowDetectorProvider(
|
||||
mask=np.ones((600, 800), dtype=np.bool_),
|
||||
backend=backend,
|
||||
clock_ns=iter((10, 30)).__next__,
|
||||
)
|
||||
|
||||
proposals = provider.detect(_packet(7, np.zeros((600, 800, 3), dtype=np.uint8)))
|
||||
|
||||
assert backend.calls == 1
|
||||
assert tuple(item.semantic_hint for item in proposals) == ("person", "dog")
|
||||
assert all(item.provider_id == RF_DETR_NATIVE_SHADOW_PROVIDER_ID for item in proposals)
|
||||
assert all(item.model_id == RF_DETR_NATIVE_SHADOW_MODEL_ID for item in proposals)
|
||||
assert all(item.preprocess_id == RF_DETR_NATIVE_SHADOW_PREPROCESS_ID for item in proposals)
|
||||
assert provider.snapshot().completed_frames == 1
|
||||
assert provider.snapshot().proposal_count == 2
|
||||
assert provider.snapshot().core_duration_ns == 20
|
||||
|
||||
|
||||
def test_native_shadow_provider_reports_prepare_transport_and_postprocess_timing() -> None:
|
||||
observed: list[DetectorFrameTiming] = []
|
||||
provider = NativeRfDetrShadowDetectorProvider(
|
||||
mask=np.ones((600, 800), dtype=np.bool_),
|
||||
backend=_Backend(_output()),
|
||||
clock_ns=iter((10, 20, 50, 70)).__next__,
|
||||
timing_observer=observed.append,
|
||||
)
|
||||
|
||||
provider.detect(_packet(7, np.zeros((600, 800, 3), dtype=np.uint8)))
|
||||
|
||||
assert [item.to_dict() for item in observed] == [
|
||||
{
|
||||
"sequence": 7,
|
||||
"preprocess_duration_ns": 10,
|
||||
"inference_transport_duration_ns": 30,
|
||||
"postprocess_duration_ns": 20,
|
||||
"total_duration_ns": 60,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_native_shadow_warmup_is_idempotent_and_excluded_from_frame_counts() -> None:
|
||||
backend = _Backend(_output())
|
||||
provider = NativeRfDetrShadowDetectorProvider(
|
||||
mask=np.ones((600, 800), dtype=np.bool_),
|
||||
backend=backend,
|
||||
clock_ns=iter((10, 20, 50, 70)).__next__,
|
||||
)
|
||||
|
||||
first = provider.warm_up()
|
||||
second = provider.warm_up()
|
||||
|
||||
assert first is second
|
||||
assert backend.calls == 1
|
||||
assert first.total_duration_ns == 60
|
||||
assert provider.snapshot().input_frames == 0
|
||||
assert provider.snapshot().completed_frames == 0
|
||||
|
||||
|
||||
def test_native_profile_is_frozen_and_transport_pins_model_version() -> None:
|
||||
assert RF_DETR_NATIVE_CONFIG.minimum_score == 0.25
|
||||
with pytest.raises(NativeRfDetrDetectorError, match="cannot be tuned"):
|
||||
NativeRfDetrConfig(minimum_score=0.5)
|
||||
|
||||
backend = TritonNativeRfDetrHttpInferenceBackend("http://127.0.0.1:8100")
|
||||
try:
|
||||
assert backend.path == (
|
||||
"/v2/models/rf_detr_large_native_kb4/versions/1/infer"
|
||||
)
|
||||
finally:
|
||||
backend.close()
|
||||
with pytest.raises(NativeRfDetrDetectorError, match="explicit HTTP origin"):
|
||||
TritonNativeRfDetrHttpInferenceBackend("http://user:secret@127.0.0.1:8100")
|
||||
|
||||
|
||||
def test_native_engine_identity_is_pinned() -> None:
|
||||
assert RF_DETR_NATIVE_ENGINE_SHA256 == (
|
||||
"b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"
|
||||
)
|
||||
|
||||
|
||||
def test_native_shadow_profile_records_failed_legacy_agreement_without_authority() -> None:
|
||||
profile = json.loads(
|
||||
(
|
||||
REPOSITORY_ROOT
|
||||
/ "config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json"
|
||||
).read_text("utf-8")
|
||||
)
|
||||
|
||||
assert profile["provider_id"] == RF_DETR_NATIVE_SHADOW_PROVIDER_ID
|
||||
assert profile["model"]["worker_006_rtx4090_tensorrt_11_engine_sha256"] == (
|
||||
RF_DETR_NATIVE_ENGINE_SHA256
|
||||
)
|
||||
assert profile["preprocessing"]["geometric_resampling"] is False
|
||||
assert profile["preprocessing"]["resize"] is False
|
||||
assert profile["qualification"]["native_pytorch_tensorrt_parity"]["passed"] is True
|
||||
assert (
|
||||
profile["qualification"]["full_ravnoves00_native_vs_legacy_704"]
|
||||
["legacy_box_agreement_gate_passed"]
|
||||
is False
|
||||
)
|
||||
assert profile["status"]["production_accepted"] is False
|
||||
assert not any(profile["authority"].values())
|
||||
assert (
|
||||
_validate_detector_profile(
|
||||
REPOSITORY_ROOT
|
||||
/ "config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json"
|
||||
)
|
||||
== "native-kb4"
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.simulation.gaussian_pipeline_gateway import (
|
||||
GaussianSourceBundleUpload,
|
||||
GaussianSourceMemberUpload,
|
||||
)
|
||||
from k1link.simulation.projects import (
|
||||
SimulationProjectConflictError,
|
||||
SimulationProjectService,
|
||||
SimulationProjectStore,
|
||||
)
|
||||
from k1link.web.simulation_projects_api import build_simulation_projects_router
|
||||
|
||||
|
||||
def _folder_files() -> list[dict[str, object]]:
|
||||
return [
|
||||
{"logical_path": "export/scene.lcc", "byte_length": 23},
|
||||
{"logical_path": "export/index.bin", "byte_length": 5},
|
||||
{"logical_path": "export/data.bin", "byte_length": 4},
|
||||
]
|
||||
|
||||
|
||||
def _upload_all(store: SimulationProjectStore, project: dict[str, Any]) -> None:
|
||||
payloads = {
|
||||
"export/scene.lcc": b'{"fileType":"Portable"}',
|
||||
"export/index.bin": b"index",
|
||||
"export/data.bin": b"data",
|
||||
}
|
||||
for source_file in project["source"]["files"]:
|
||||
store.append_upload(
|
||||
project["project_id"],
|
||||
source_file["file_id"],
|
||||
offset=0,
|
||||
payload=payloads[source_file["logical_path"]],
|
||||
)
|
||||
|
||||
|
||||
def test_store_persists_resumable_folder_upload_and_state_transitions(tmp_path: Path) -> None:
|
||||
store = SimulationProjectStore(tmp_path)
|
||||
project = store.create(
|
||||
name=" Smolensky Boulevard ",
|
||||
scene_type="outdoor",
|
||||
source_kind="folder",
|
||||
files=_folder_files(),
|
||||
)
|
||||
assert project["name"] == "Smolensky Boulevard"
|
||||
first = project["source"]["files"][0]
|
||||
store.append_upload(
|
||||
project["project_id"],
|
||||
first["file_id"],
|
||||
offset=0,
|
||||
payload=b'{"fileType":',
|
||||
)
|
||||
with pytest.raises(SimulationProjectConflictError, match="offset"):
|
||||
store.append_upload(
|
||||
project["project_id"],
|
||||
first["file_id"],
|
||||
offset=0,
|
||||
payload=b'"Portable"}',
|
||||
)
|
||||
store.append_upload(
|
||||
project["project_id"],
|
||||
first["file_id"],
|
||||
offset=len(b'{"fileType":'),
|
||||
payload=b'"Portable"}',
|
||||
)
|
||||
for source_file, payload in zip(
|
||||
project["source"]["files"][1:],
|
||||
(b"index", b"data"),
|
||||
strict=True,
|
||||
):
|
||||
store.append_upload(
|
||||
project["project_id"],
|
||||
source_file["file_id"],
|
||||
offset=0,
|
||||
payload=payload,
|
||||
)
|
||||
queued = store.begin_build(project["project_id"])
|
||||
assert queued["status"] == "queued"
|
||||
with pytest.raises(SimulationProjectConflictError, match="active"):
|
||||
store.delete(project["project_id"])
|
||||
|
||||
|
||||
class _ReadyProvider:
|
||||
def __init__(self) -> None:
|
||||
self.deleted: list[str] = []
|
||||
self.upload_calls = 0
|
||||
self.submit_calls = 0
|
||||
|
||||
def capabilities(self) -> dict[str, object]:
|
||||
return {"outputs": ["preview.sog", "streamed-sog"]}
|
||||
|
||||
def upload_source_bundle(
|
||||
self,
|
||||
_root: Path,
|
||||
*,
|
||||
entrypoint: str,
|
||||
source_format: str,
|
||||
) -> GaussianSourceBundleUpload:
|
||||
self.upload_calls += 1
|
||||
members = (
|
||||
GaussianSourceMemberUpload("upload-1", entrypoint, "a" * 64, 23),
|
||||
GaussianSourceMemberUpload("upload-2", "export/data.bin", "b" * 64, 4),
|
||||
GaussianSourceMemberUpload("upload-3", "export/index.bin", "c" * 64, 5),
|
||||
)
|
||||
return GaussianSourceBundleUpload(
|
||||
format=source_format,
|
||||
entrypoint=entrypoint,
|
||||
bundle_sha256="d" * 64,
|
||||
total_byte_length=32,
|
||||
members=members,
|
||||
)
|
||||
|
||||
def submit_build(self, _document: dict[str, object]) -> dict[str, object]:
|
||||
self.submit_calls += 1
|
||||
return {
|
||||
"schema_version": "gaussian-pipeline.job/v1",
|
||||
"job_id": "gsp-20260826000000-deadbeef",
|
||||
"state": "queued",
|
||||
}
|
||||
|
||||
def get_job(self, _job_id: str) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "gaussian-pipeline.job/v1",
|
||||
"job_id": "gsp-20260826000000-deadbeef",
|
||||
"state": "ready",
|
||||
"progress": {"completed_steps": 4, "total_steps": 4},
|
||||
}
|
||||
|
||||
def get_result(self, _job_id: str) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "gaussian-pipeline.build-result/v1",
|
||||
"job_id": "gsp-20260826000000-deadbeef",
|
||||
"runtime": {
|
||||
"source_revision": "e" * 40,
|
||||
"image_digest": f"sha256:{'f' * 64}",
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"role": "preview",
|
||||
"logical_path": "preview.sog",
|
||||
"media_type": "application/octet-stream",
|
||||
"sha256": "1" * 64,
|
||||
"byte_length": 7,
|
||||
},
|
||||
{
|
||||
"role": "stream-manifest",
|
||||
"logical_path": "streamed/lod-meta.json",
|
||||
"media_type": "application/json",
|
||||
"sha256": "2" * 64,
|
||||
"byte_length": 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def download_artifact(
|
||||
self,
|
||||
_job_id: str,
|
||||
descriptor: dict[str, object],
|
||||
destination: Path,
|
||||
) -> Path:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(b"preview" if descriptor["role"] == "preview" else b"{}")
|
||||
return destination
|
||||
|
||||
def delete_job(self, job_id: str) -> None:
|
||||
self.deleted.append(job_id)
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_service_materializes_world_manifest_and_deletes_both_copies(tmp_path: Path) -> None:
|
||||
store = SimulationProjectStore(tmp_path)
|
||||
project = store.create(
|
||||
name="MOJJOKER",
|
||||
scene_type="interior",
|
||||
source_kind="folder",
|
||||
files=_folder_files(),
|
||||
)
|
||||
_upload_all(store, project)
|
||||
store.begin_build(project["project_id"])
|
||||
provider = _ReadyProvider()
|
||||
service = SimulationProjectService(store, provider_factory=lambda: provider) # type: ignore[arg-type]
|
||||
service.process(project["project_id"])
|
||||
|
||||
ready = store.get(project["project_id"])
|
||||
assert ready["status"] == "ready"
|
||||
assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog")
|
||||
assert ready["world_manifest"]["collision"]["available"] is False
|
||||
assert provider.upload_calls == 1
|
||||
assert provider.submit_calls == 1
|
||||
service.delete(project["project_id"])
|
||||
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
|
||||
|
||||
|
||||
def test_service_resumes_a_persisted_provider_job_without_reupload(tmp_path: Path) -> None:
|
||||
store = SimulationProjectStore(tmp_path)
|
||||
project = store.create(
|
||||
name="Restart-safe scene",
|
||||
scene_type="outdoor",
|
||||
source_kind="folder",
|
||||
files=_folder_files(),
|
||||
)
|
||||
_upload_all(store, project)
|
||||
store.begin_build(project["project_id"])
|
||||
store.update_processing(
|
||||
project["project_id"],
|
||||
status="processing",
|
||||
provider_job_id="gsp-20260826000000-deadbeef",
|
||||
provider_state="building_streamed_sog",
|
||||
bundle_sha256="d" * 64,
|
||||
)
|
||||
provider = _ReadyProvider()
|
||||
service = SimulationProjectService(store, provider_factory=lambda: provider) # type: ignore[arg-type]
|
||||
|
||||
service.process(project["project_id"])
|
||||
|
||||
assert store.get(project["project_id"])["status"] == "ready"
|
||||
assert provider.upload_calls == 0
|
||||
assert provider.submit_calls == 0
|
||||
|
||||
|
||||
def test_api_exposes_same_origin_resumable_upload_contract(tmp_path: Path) -> None:
|
||||
store = SimulationProjectStore(tmp_path)
|
||||
service = SimulationProjectService(store, provider_factory=lambda: None)
|
||||
app = FastAPI()
|
||||
app.include_router(build_simulation_projects_router(store=store, service=service))
|
||||
client = TestClient(app)
|
||||
created = client.post("/api/v1/simulation-worlds/projects", json={
|
||||
"schema_version": "missioncore.simulation-project-create/v1",
|
||||
"name": "Archive scene",
|
||||
"scene_type": "outdoor",
|
||||
"source_kind": "archive",
|
||||
"files": [{"logical_path": "scan.rar", "byte_length": 6}],
|
||||
})
|
||||
assert created.status_code == 201
|
||||
project = created.json()
|
||||
source_file = project["source"]["files"][0]
|
||||
upload_url = (
|
||||
f"/api/v1/simulation-worlds/projects/{project['project_id']}"
|
||||
f"/source/{source_file['file_id']}"
|
||||
)
|
||||
assert client.head(upload_url).headers["upload-offset"] == "0"
|
||||
first = client.patch(upload_url, headers={"Upload-Offset": "0"}, content=b"abc")
|
||||
assert first.status_code == 204
|
||||
assert first.headers["upload-offset"] == "3"
|
||||
mismatch = client.patch(upload_url, headers={"Upload-Offset": "0"}, content=b"def")
|
||||
assert mismatch.status_code == 409
|
||||
second = client.patch(upload_url, headers={"Upload-Offset": "3"}, content=b"def")
|
||||
assert second.status_code == 204
|
||||
catalog = client.get("/api/v1/simulation-worlds/projects").json()
|
||||
assert catalog["projects"][0]["project_id"] == project["project_id"]
|
||||
@@ -25,6 +25,8 @@ def _gateway(tmp_path: Path, status: int = 200) -> GaussianPipelineGateway:
|
||||
"api_version": "gaussian-pipeline.api/v1",
|
||||
"upload_protocol": "tus/1.0.0",
|
||||
"source_transport": "tus-bundle/v1",
|
||||
"archive_transport": "tus-archive/v1",
|
||||
"archive_formats": ["zip", "rar", "7z"],
|
||||
"provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"},
|
||||
"runtime": {
|
||||
"source_revision": "a" * 40,
|
||||
|
||||
Reference in New Issue
Block a user