feat(lab): visualize semantic SLAM shadow
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
type CSSProperties,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -8,6 +9,13 @@ import {
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
recordedEvidenceSemanticCssColor,
|
||||||
|
resolveRecordedEvidenceSemanticRgb,
|
||||||
|
type RecordedEvidenceSemanticClass,
|
||||||
|
type RecordedEvidenceSemanticPaletteEntry,
|
||||||
|
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||||
|
|
||||||
export type LaboratoryMetricPoint3 = readonly [number, number, number];
|
export type LaboratoryMetricPoint3 = readonly [number, number, number];
|
||||||
export type LaboratoryMetricDecision = "threat" | "not-threat" | "unknown";
|
export type LaboratoryMetricDecision = "threat" | "not-threat" | "unknown";
|
||||||
export type LaboratoryMetricSceneMode = "3d" | "plan";
|
export type LaboratoryMetricSceneMode = "3d" | "plan";
|
||||||
@@ -114,6 +122,9 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
showCurrentIncrement: boolean;
|
showCurrentIncrement: boolean;
|
||||||
showLocalSurface: boolean;
|
showLocalSurface: boolean;
|
||||||
showRollingMap: boolean;
|
showRollingMap: boolean;
|
||||||
|
pointSemanticClassIds?: readonly (number | null)[];
|
||||||
|
semanticClasses?: readonly RecordedEvidenceSemanticClass[];
|
||||||
|
semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||||
}
|
}
|
||||||
>(function LaboratoryMetricEvidenceScene({
|
>(function LaboratoryMetricEvidenceScene({
|
||||||
pointCloudBodyXyzM,
|
pointCloudBodyXyzM,
|
||||||
@@ -127,6 +138,9 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showLocalSurface,
|
showLocalSurface,
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
|
pointSemanticClassIds,
|
||||||
|
semanticClasses,
|
||||||
|
semanticPalette,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||||
@@ -240,10 +254,40 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
"position",
|
"position",
|
||||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||||
);
|
);
|
||||||
|
const hasAlignedSemanticClasses =
|
||||||
|
pointSemanticClassIds !== undefined
|
||||||
|
&& pointSemanticClassIds.length === pointCloudBodyXyzM.length
|
||||||
|
&& semanticClasses !== undefined
|
||||||
|
&& semanticPalette !== undefined;
|
||||||
|
if (hasAlignedSemanticClasses) {
|
||||||
|
const declaredIds = new Set(semanticClasses.map((item) => item.id));
|
||||||
|
const colorsByClassId = new Map<number, readonly [number, number, number]>();
|
||||||
|
for (const entry of semanticPalette) {
|
||||||
|
if (!declaredIds.has(entry.classId)) continue;
|
||||||
|
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||||
|
if (rgb) colorsByClassId.set(entry.classId, rgb);
|
||||||
|
}
|
||||||
|
const context = tokenColor(host, "--nodedc-text-muted", [147, 151, 159]);
|
||||||
|
const pointColors = new Float32Array(pointCloudBodyXyzM.length * 3);
|
||||||
|
pointSemanticClassIds.forEach((classId, index) => {
|
||||||
|
const rgb = classId === null ? undefined : colorsByClassId.get(classId);
|
||||||
|
const offset = index * 3;
|
||||||
|
pointColors[offset] = rgb ? rgb[0] / 255 : context.r;
|
||||||
|
pointColors[offset + 1] = rgb ? rgb[1] / 255 : context.g;
|
||||||
|
pointColors[offset + 2] = rgb ? rgb[2] / 255 : context.b;
|
||||||
|
});
|
||||||
|
contextGeometry.setAttribute(
|
||||||
|
"color",
|
||||||
|
new THREE.BufferAttribute(pointColors, 3),
|
||||||
|
);
|
||||||
|
}
|
||||||
content.add(new THREE.Points(
|
content.add(new THREE.Points(
|
||||||
contextGeometry,
|
contextGeometry,
|
||||||
new THREE.PointsMaterial({
|
new THREE.PointsMaterial({
|
||||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
color: hasAlignedSemanticClasses
|
||||||
|
? new THREE.Color(1, 1, 1)
|
||||||
|
: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||||
|
vertexColors: hasAlignedSemanticClasses,
|
||||||
size: 1.55,
|
size: 1.55,
|
||||||
sizeAttenuation: false,
|
sizeAttenuation: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -321,6 +365,9 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
occupiedVoxelSizeM,
|
occupiedVoxelSizeM,
|
||||||
localSurfaceBodyXyzM,
|
localSurfaceBodyXyzM,
|
||||||
pointCloudBodyXyzM,
|
pointCloudBodyXyzM,
|
||||||
|
pointSemanticClassIds,
|
||||||
|
semanticClasses,
|
||||||
|
semanticPalette,
|
||||||
showCurrentIncrement,
|
showCurrentIncrement,
|
||||||
showLocalSurface,
|
showLocalSurface,
|
||||||
showRollingMap,
|
showRollingMap,
|
||||||
@@ -411,6 +458,27 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
useEffect(resetView, [corridor.forwardLengthM, mode]);
|
||||||
useImperativeHandle(ref, () => ({ resetView }));
|
useImperativeHandle(ref, () => ({ resetView }));
|
||||||
|
|
||||||
|
const semanticLegendEntries = (() => {
|
||||||
|
if (
|
||||||
|
!showCurrentIncrement
|
||||||
|
|| !pointSemanticClassIds
|
||||||
|
|| pointSemanticClassIds.length !== pointCloudBodyXyzM.length
|
||||||
|
|| !semanticClasses
|
||||||
|
|| !semanticPalette
|
||||||
|
) return [];
|
||||||
|
const presentIds = new Set(pointSemanticClassIds.filter((item): item is number => item !== null));
|
||||||
|
const classesById = new Map(semanticClasses.map((item) => [item.id, item]));
|
||||||
|
return semanticPalette.flatMap((entry) => {
|
||||||
|
const semanticClass = classesById.get(entry.classId);
|
||||||
|
if (!semanticClass || !presentIds.has(entry.classId) || entry.color.kind === "transparent") return [];
|
||||||
|
return [{
|
||||||
|
id: entry.classId,
|
||||||
|
label: semanticClass.label,
|
||||||
|
cssColor: recordedEvidenceSemanticCssColor(entry.color),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="laboratory-metric-evidence-scene">
|
<div className="laboratory-metric-evidence-scene">
|
||||||
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
|
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
|
||||||
@@ -423,6 +491,15 @@ LaboratoryMetricEvidenceSceneHandle,
|
|||||||
<span data-decision="context">Current increment</span>
|
<span data-decision="context">Current increment</span>
|
||||||
<span data-decision="local-surface">Local SLAM surface</span>
|
<span data-decision="local-surface">Local SLAM surface</span>
|
||||||
<span data-decision="rolling">Rolling-map occupied</span>
|
<span data-decision="rolling">Rolling-map occupied</span>
|
||||||
|
{semanticLegendEntries.map((entry) => (
|
||||||
|
<span
|
||||||
|
key={entry.id}
|
||||||
|
data-decision="semantic"
|
||||||
|
style={{ "--laboratory-metric-legend-color": entry.cssColor } as CSSProperties}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,18 +5,24 @@ import {
|
|||||||
RecordedEvidenceBoxOverlay,
|
RecordedEvidenceBoxOverlay,
|
||||||
type RecordedEvidenceBox,
|
type RecordedEvidenceBox,
|
||||||
} from "./RecordedEvidenceBoxOverlay";
|
} from "./RecordedEvidenceBoxOverlay";
|
||||||
|
import {
|
||||||
|
RecordedEvidenceSemanticMaskOverlay,
|
||||||
|
type RecordedEvidenceSemanticOverlay,
|
||||||
|
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||||
|
|
||||||
export function RecordedEvidenceImageScene({
|
export function RecordedEvidenceImageScene({
|
||||||
src,
|
src,
|
||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
boxes,
|
boxes,
|
||||||
|
semanticOverlay,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
boxes: readonly RecordedEvidenceBox[];
|
boxes: readonly RecordedEvidenceBox[];
|
||||||
|
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
@@ -33,12 +39,21 @@ export function RecordedEvidenceImageScene({
|
|||||||
onError={() => setState("error")}
|
onError={() => setState("error")}
|
||||||
/>
|
/>
|
||||||
{state === "ready" ? (
|
{state === "ready" ? (
|
||||||
<RecordedEvidenceBoxOverlay
|
<>
|
||||||
imageWidth={imageWidth}
|
{semanticOverlay ? (
|
||||||
imageHeight={imageHeight}
|
<RecordedEvidenceSemanticMaskOverlay
|
||||||
boxes={boxes}
|
{...semanticOverlay}
|
||||||
ariaLabel={ariaLabel}
|
imageWidth={imageWidth}
|
||||||
/>
|
imageHeight={imageHeight}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<RecordedEvidenceBoxOverlay
|
||||||
|
imageWidth={imageWidth}
|
||||||
|
imageHeight={imageHeight}
|
||||||
|
boxes={boxes}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="l3-visual-audit__state" role="status">
|
<div className="l3-visual-audit__state" role="status">
|
||||||
{state === "loading" ? (
|
{state === "loading" ? (
|
||||||
|
|||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export interface RecordedEvidenceSemanticClass {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordedEvidenceSemanticToken =
|
||||||
|
| "--nodedc-accent-rgb"
|
||||||
|
| "--nodedc-danger-rgb"
|
||||||
|
| "--nodedc-foreground-rgb"
|
||||||
|
| "--nodedc-success-rgb"
|
||||||
|
| "--nodedc-text-muted"
|
||||||
|
| "--nodedc-warning-rgb";
|
||||||
|
|
||||||
|
export type RecordedEvidenceSemanticColor =
|
||||||
|
| {
|
||||||
|
kind: "token";
|
||||||
|
token: RecordedEvidenceSemanticToken;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "diagnostic";
|
||||||
|
rgb: readonly [number, number, number];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "transparent";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RecordedEvidenceSemanticPaletteEntry {
|
||||||
|
classId: number;
|
||||||
|
color: RecordedEvidenceSemanticColor;
|
||||||
|
opacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordedEvidenceSemanticOverlay {
|
||||||
|
src: string;
|
||||||
|
classes: readonly RecordedEvidenceSemanticClass[];
|
||||||
|
palette: readonly RecordedEvidenceSemanticPaletteEntry[];
|
||||||
|
opacity?: number;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecodedSemanticMask {
|
||||||
|
key: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
classIds: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingSemanticMask {
|
||||||
|
controller: AbortController;
|
||||||
|
subscribers: number;
|
||||||
|
promise: Promise<DecodedSemanticMask>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASK_CACHE_LIMIT = 48;
|
||||||
|
const decodedMaskCache = new Map<string, DecodedSemanticMask>();
|
||||||
|
const pendingMaskCache = new Map<string, PendingSemanticMask>();
|
||||||
|
|
||||||
|
const TOKEN_FALLBACKS: Record<RecordedEvidenceSemanticToken, readonly [number, number, number]> = {
|
||||||
|
"--nodedc-accent-rgb": [232, 56, 126],
|
||||||
|
"--nodedc-danger-rgb": [255, 104, 112],
|
||||||
|
"--nodedc-foreground-rgb": [245, 245, 245],
|
||||||
|
"--nodedc-success-rgb": [181, 255, 90],
|
||||||
|
"--nodedc-text-muted": [147, 151, 159],
|
||||||
|
"--nodedc-warning-rgb": [255, 197, 92],
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampChannel(value: number): number {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOpacity(value: number | undefined, fallback: number): number {
|
||||||
|
return Math.max(0, Math.min(1, Number.isFinite(value) ? Number(value) : fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticMaskKey(src: string, width: number, height: number): string {
|
||||||
|
return `${width}x${height}:${src}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberMask(mask: DecodedSemanticMask): void {
|
||||||
|
decodedMaskCache.delete(mask.key);
|
||||||
|
decodedMaskCache.set(mask.key, mask);
|
||||||
|
while (decodedMaskCache.size > MASK_CACHE_LIMIT) {
|
||||||
|
const oldest = decodedMaskCache.keys().next().value;
|
||||||
|
if (typeof oldest !== "string") break;
|
||||||
|
decodedMaskCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageSourceFromBlob(
|
||||||
|
blob: Blob,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<{ source: CanvasImageSource; width: number; height: number; release: () => void }> {
|
||||||
|
if (typeof createImageBitmap === "function") {
|
||||||
|
const bitmap = await createImageBitmap(blob, {
|
||||||
|
colorSpaceConversion: "none",
|
||||||
|
premultiplyAlpha: "none",
|
||||||
|
});
|
||||||
|
if (signal.aborted) {
|
||||||
|
bitmap.close();
|
||||||
|
throw new DOMException("Aborted", "AbortError");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
source: bitmap,
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
release: () => bitmap.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = "async";
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
image.removeEventListener("load", onLoad);
|
||||||
|
image.removeEventListener("error", onError);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const onLoad = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const onError = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Semantic mask image decode failed"));
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
};
|
||||||
|
image.addEventListener("load", onLoad, { once: true });
|
||||||
|
image.addEventListener("error", onError, { once: true });
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
source: image,
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight,
|
||||||
|
release: () => URL.revokeObjectURL(objectUrl),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeSemanticMask(
|
||||||
|
key: string,
|
||||||
|
src: string,
|
||||||
|
expectedWidth: number,
|
||||||
|
expectedHeight: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<DecodedSemanticMask> {
|
||||||
|
const response = await fetch(src, { cache: "force-cache", signal });
|
||||||
|
if (!response.ok) throw new Error(`Semantic mask request failed: ${response.status}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
const decoded = await imageSourceFromBlob(blob, signal);
|
||||||
|
try {
|
||||||
|
if (decoded.width !== expectedWidth || decoded.height !== expectedHeight) {
|
||||||
|
throw new Error(
|
||||||
|
`Semantic mask dimensions ${decoded.width}x${decoded.height} do not match ${expectedWidth}x${expectedHeight}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const decodeCanvas = document.createElement("canvas");
|
||||||
|
decodeCanvas.width = decoded.width;
|
||||||
|
decodeCanvas.height = decoded.height;
|
||||||
|
const context = decodeCanvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) throw new Error("Semantic mask canvas is unavailable");
|
||||||
|
context.drawImage(decoded.source, 0, 0);
|
||||||
|
const rgba = context.getImageData(0, 0, decoded.width, decoded.height).data;
|
||||||
|
const classIds = new Uint8Array(decoded.width * decoded.height);
|
||||||
|
for (let sourceOffset = 0, targetOffset = 0; targetOffset < classIds.length; sourceOffset += 4, targetOffset += 1) {
|
||||||
|
const classId = rgba[sourceOffset] ?? 0;
|
||||||
|
if (rgba[sourceOffset + 1] !== classId || rgba[sourceOffset + 2] !== classId) {
|
||||||
|
throw new Error("Semantic mask must be an 8-bit grayscale class-id PNG");
|
||||||
|
}
|
||||||
|
classIds[targetOffset] = classId;
|
||||||
|
}
|
||||||
|
return { key, width: decoded.width, height: decoded.height, classIds };
|
||||||
|
} finally {
|
||||||
|
decoded.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeToSemanticMask(
|
||||||
|
src: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): { promise: Promise<DecodedSemanticMask>; release: () => void } {
|
||||||
|
const key = semanticMaskKey(src, width, height);
|
||||||
|
const cached = decodedMaskCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
decodedMaskCache.delete(key);
|
||||||
|
decodedMaskCache.set(key, cached);
|
||||||
|
return { promise: Promise.resolve(cached), release: () => undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = pendingMaskCache.get(key);
|
||||||
|
if (!pending) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let next: PendingSemanticMask;
|
||||||
|
const promise = decodeSemanticMask(key, src, width, height, controller.signal)
|
||||||
|
.then((mask) => {
|
||||||
|
rememberMask(mask);
|
||||||
|
return mask;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (pendingMaskCache.get(key) === next) pendingMaskCache.delete(key);
|
||||||
|
});
|
||||||
|
next = { controller, subscribers: 0, promise };
|
||||||
|
pendingMaskCache.set(key, next);
|
||||||
|
pending = next;
|
||||||
|
}
|
||||||
|
pending.subscribers += 1;
|
||||||
|
let released = false;
|
||||||
|
return {
|
||||||
|
promise: pending.promise,
|
||||||
|
release: () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
pending!.subscribers -= 1;
|
||||||
|
if (pending!.subscribers > 0 || decodedMaskCache.has(key)) return;
|
||||||
|
pending!.controller.abort();
|
||||||
|
if (pendingMaskCache.get(key) === pending) pendingMaskCache.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordedEvidenceSemanticCssColor(
|
||||||
|
color: RecordedEvidenceSemanticColor,
|
||||||
|
): string {
|
||||||
|
if (color.kind === "transparent") return "transparent";
|
||||||
|
if (color.kind === "token") return `rgb(var(${color.token}))`;
|
||||||
|
const [red, green, blue] = color.rgb.map(clampChannel);
|
||||||
|
return `rgb(${red} ${green} ${blue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRecordedEvidenceSemanticRgb(
|
||||||
|
host: HTMLElement,
|
||||||
|
color: RecordedEvidenceSemanticColor,
|
||||||
|
): readonly [number, number, number] | null {
|
||||||
|
if (color.kind === "transparent") return null;
|
||||||
|
if (color.kind === "diagnostic") return color.rgb.map(clampChannel) as [number, number, number];
|
||||||
|
const channels = getComputedStyle(host)
|
||||||
|
.getPropertyValue(color.token)
|
||||||
|
.trim()
|
||||||
|
.match(/[\d.]+/g)
|
||||||
|
?.slice(0, 3)
|
||||||
|
.map(Number);
|
||||||
|
return channels?.length === 3
|
||||||
|
? channels.map(clampChannel) as [number, number, number]
|
||||||
|
: TOKEN_FALLBACKS[color.token];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordedEvidenceSemanticMaskOverlay({
|
||||||
|
src,
|
||||||
|
imageWidth,
|
||||||
|
imageHeight,
|
||||||
|
classes,
|
||||||
|
palette,
|
||||||
|
opacity = 0.46,
|
||||||
|
ariaLabel,
|
||||||
|
}: RecordedEvidenceSemanticOverlay & {
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [mask, setMask] = useState<DecodedSemanticMask | null>(null);
|
||||||
|
const [failure, setFailure] = useState<string | null>(null);
|
||||||
|
const expectedKey = semanticMaskKey(src, imageWidth, imageHeight);
|
||||||
|
const renderMask = failure
|
||||||
|
? null
|
||||||
|
: mask?.key === expectedKey
|
||||||
|
? mask
|
||||||
|
: decodedMaskCache.get(expectedKey) ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFailure(null);
|
||||||
|
const subscription = subscribeToSemanticMask(src, imageWidth, imageHeight);
|
||||||
|
let current = true;
|
||||||
|
void subscription.promise.then((decoded) => {
|
||||||
|
if (!current || decoded.key !== expectedKey) return;
|
||||||
|
const declaredClassIds = new Set(classes.map((item) => item.id));
|
||||||
|
const undeclaredClassId = decoded.classIds.find(
|
||||||
|
(classId) => !declaredClassIds.has(classId),
|
||||||
|
);
|
||||||
|
if (undeclaredClassId !== undefined) {
|
||||||
|
setFailure(`Semantic mask содержит необъявленный class ID ${undeclaredClassId}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMask(decoded);
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
if (!current || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||||
|
setFailure("Semantic mask не прошла проверку или декодирование.");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
current = false;
|
||||||
|
subscription.release();
|
||||||
|
};
|
||||||
|
}, [classes, expectedKey, imageHeight, imageWidth, src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const host = canvas?.parentElement;
|
||||||
|
if (!canvas || !host) return;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const width = Math.max(host.clientWidth, 1);
|
||||||
|
const height = Math.max(host.clientHeight, 1);
|
||||||
|
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||||
|
canvas.width = Math.round(width * pixelRatio);
|
||||||
|
canvas.height = Math.round(height * pixelRatio);
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
if (!renderMask) return;
|
||||||
|
|
||||||
|
const declaredClassIds = new Set(
|
||||||
|
classes
|
||||||
|
.map((item) => item.id)
|
||||||
|
.filter((classId) => Number.isInteger(classId) && classId >= 0 && classId <= 255),
|
||||||
|
);
|
||||||
|
const resolvedPalette = new Map<number, { rgb: readonly [number, number, number]; alpha: number }>();
|
||||||
|
for (const entry of palette) {
|
||||||
|
if (!declaredClassIds.has(entry.classId)) continue;
|
||||||
|
const rgb = resolveRecordedEvidenceSemanticRgb(host, entry.color);
|
||||||
|
if (!rgb) continue;
|
||||||
|
resolvedPalette.set(entry.classId, {
|
||||||
|
rgb,
|
||||||
|
alpha: clampOpacity(entry.opacity, 1) * clampOpacity(opacity, 0.46),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorCanvas = document.createElement("canvas");
|
||||||
|
colorCanvas.width = renderMask.width;
|
||||||
|
colorCanvas.height = renderMask.height;
|
||||||
|
const colorContext = colorCanvas.getContext("2d");
|
||||||
|
if (!colorContext) return;
|
||||||
|
const imageData = colorContext.createImageData(renderMask.width, renderMask.height);
|
||||||
|
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < renderMask.classIds.length; sourceOffset += 1, targetOffset += 4) {
|
||||||
|
const color = resolvedPalette.get(renderMask.classIds[sourceOffset] ?? -1);
|
||||||
|
if (!color) continue;
|
||||||
|
imageData.data[targetOffset] = color.rgb[0];
|
||||||
|
imageData.data[targetOffset + 1] = color.rgb[1];
|
||||||
|
imageData.data[targetOffset + 2] = color.rgb[2];
|
||||||
|
imageData.data[targetOffset + 3] = Math.round(color.alpha * 255);
|
||||||
|
}
|
||||||
|
colorContext.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||||
|
const drawWidth = imageWidth * scale;
|
||||||
|
const drawHeight = imageHeight * scale;
|
||||||
|
const offsetX = (width - drawWidth) / 2;
|
||||||
|
const offsetY = (height - drawHeight) / 2;
|
||||||
|
context.imageSmoothingEnabled = false;
|
||||||
|
context.drawImage(colorCanvas, offsetX, offsetY, drawWidth, drawHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(render);
|
||||||
|
observer.observe(host);
|
||||||
|
render();
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [classes, expectedKey, imageHeight, imageWidth, opacity, palette, renderMask]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="recorded-evidence-semantic-mask-overlay"
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-busy={!failure && !renderMask}
|
||||||
|
data-state={failure ? "error" : renderMask ? "ready" : "loading"}
|
||||||
|
style={{ zIndex: 1 }}
|
||||||
|
/>
|
||||||
|
{failure ? (
|
||||||
|
<div className="recorded-evidence-semantic-mask-overlay__error" role="alert">
|
||||||
|
{failure}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
type RecordedEvidenceBox,
|
type RecordedEvidenceBox,
|
||||||
type RecordedEvidenceBoxTone,
|
type RecordedEvidenceBoxTone,
|
||||||
} from "./RecordedEvidenceBoxOverlay";
|
} from "./RecordedEvidenceBoxOverlay";
|
||||||
|
import {
|
||||||
|
RecordedEvidenceSemanticMaskOverlay,
|
||||||
|
type RecordedEvidenceSemanticOverlay,
|
||||||
|
} from "./RecordedEvidenceSemanticMaskOverlay";
|
||||||
|
|
||||||
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
|
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
|
||||||
|
|
||||||
@@ -17,6 +21,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
boxes,
|
boxes,
|
||||||
|
semanticOverlay,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
interactive = true,
|
interactive = true,
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
@@ -26,6 +31,7 @@ export function RecordedEvidenceVideoScene({
|
|||||||
imageWidth: number;
|
imageWidth: number;
|
||||||
imageHeight: number;
|
imageHeight: number;
|
||||||
boxes: readonly RecordedEvidenceBox[];
|
boxes: readonly RecordedEvidenceBox[];
|
||||||
|
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||||
@@ -39,6 +45,13 @@ export function RecordedEvidenceVideoScene({
|
|||||||
prepare
|
prepare
|
||||||
onPlaybackChange={onPlaybackChange}
|
onPlaybackChange={onPlaybackChange}
|
||||||
/>
|
/>
|
||||||
|
{semanticOverlay ? (
|
||||||
|
<RecordedEvidenceSemanticMaskOverlay
|
||||||
|
{...semanticOverlay}
|
||||||
|
imageWidth={imageWidth}
|
||||||
|
imageHeight={imageHeight}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<RecordedEvidenceBoxOverlay
|
<RecordedEvidenceBoxOverlay
|
||||||
imageWidth={imageWidth}
|
imageWidth={imageWidth}
|
||||||
imageHeight={imageHeight}
|
imageHeight={imageHeight}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { fetchE46GRectifiedDetectorBakeoff } from "./e46gRectifiedDetectorBakeof
|
|||||||
import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
|
import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
|
||||||
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
||||||
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||||
|
import { fetchE47SemanticSlamResult } from "./e47SemanticSlam";
|
||||||
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||||
|
|
||||||
export type AdvancedLaboratoryWorkId =
|
export type AdvancedLaboratoryWorkId =
|
||||||
@@ -63,6 +64,7 @@ export type AdvancedLaboratoryWorkId =
|
|||||||
| "e46h-full-rectified-front-replay"
|
| "e46h-full-rectified-front-replay"
|
||||||
| "e46i-grounding-dino-full-replay"
|
| "e46i-grounding-dino-full-replay"
|
||||||
| "e46j-raw-fisheye-realtime"
|
| "e46j-raw-fisheye-realtime"
|
||||||
|
| "e47-semantic-slam-shadow"
|
||||||
| "l34-right-yolox-truth-island-freeze"
|
| "l34-right-yolox-truth-island-freeze"
|
||||||
| "l34a-assisted-yolox-error-audit"
|
| "l34a-assisted-yolox-error-audit"
|
||||||
| "l34b-nested-box-consolidation-shadow"
|
| "l34b-nested-box-consolidation-shadow"
|
||||||
@@ -103,6 +105,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
|||||||
"e46h-full-rectified-front-replay",
|
"e46h-full-rectified-front-replay",
|
||||||
"e46i-grounding-dino-full-replay",
|
"e46i-grounding-dino-full-replay",
|
||||||
"e46j-raw-fisheye-realtime",
|
"e46j-raw-fisheye-realtime",
|
||||||
|
"e47-semantic-slam-shadow",
|
||||||
"l34-right-yolox-truth-island-freeze",
|
"l34-right-yolox-truth-island-freeze",
|
||||||
"l34a-assisted-yolox-error-audit",
|
"l34a-assisted-yolox-error-audit",
|
||||||
"l34b-nested-box-consolidation-shadow",
|
"l34b-nested-box-consolidation-shadow",
|
||||||
@@ -138,6 +141,7 @@ const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
|||||||
"e46h-full-rectified-front-replay": "e46h-full-rectified-front-replay",
|
"e46h-full-rectified-front-replay": "e46h-full-rectified-front-replay",
|
||||||
"e46i-grounding-dino-full-replay": "e46i-grounding-dino-full-replay",
|
"e46i-grounding-dino-full-replay": "e46i-grounding-dino-full-replay",
|
||||||
"e46j-raw-fisheye-realtime": "e46j-raw-fisheye-realtime",
|
"e46j-raw-fisheye-realtime": "e46j-raw-fisheye-realtime",
|
||||||
|
"e47-semantic-slam-shadow": "e47-semantic-slam",
|
||||||
"l34-right-yolox-truth-island-freeze": "l34-right-yolox-truth-island-freeze",
|
"l34-right-yolox-truth-island-freeze": "l34-right-yolox-truth-island-freeze",
|
||||||
"l34a-assisted-yolox-error-audit": "l34a-assisted-yolox-error-audit",
|
"l34a-assisted-yolox-error-audit": "l34a-assisted-yolox-error-audit",
|
||||||
"l34b-nested-box-consolidation-shadow": "l34b-nested-box-consolidation-shadow",
|
"l34b-nested-box-consolidation-shadow": "l34b-nested-box-consolidation-shadow",
|
||||||
@@ -180,6 +184,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
|||||||
e46h: null,
|
e46h: null,
|
||||||
e46i: null,
|
e46i: null,
|
||||||
e46j: null,
|
e46j: null,
|
||||||
|
e47: null,
|
||||||
l34: null,
|
l34: null,
|
||||||
l34a: null,
|
l34a: null,
|
||||||
l34b: null,
|
l34b: null,
|
||||||
@@ -303,6 +308,7 @@ export function advancedLaboratoryResultAvailable(
|
|||||||
: workId === "e46h-full-rectified-front-replay" ? results.e46h !== null
|
: workId === "e46h-full-rectified-front-replay" ? results.e46h !== null
|
||||||
: workId === "e46i-grounding-dino-full-replay" ? results.e46i !== null
|
: workId === "e46i-grounding-dino-full-replay" ? results.e46i !== null
|
||||||
: workId === "e46j-raw-fisheye-realtime" ? results.e46j !== null
|
: workId === "e46j-raw-fisheye-realtime" ? results.e46j !== null
|
||||||
|
: workId === "e47-semantic-slam-shadow" ? results.e47 !== null
|
||||||
: workId === "l34-right-yolox-truth-island-freeze" ? results.l34 !== null
|
: workId === "l34-right-yolox-truth-island-freeze" ? results.l34 !== null
|
||||||
: workId === "l34a-assisted-yolox-error-audit" ? results.l34a !== null
|
: workId === "l34a-assisted-yolox-error-audit" ? results.l34a !== null
|
||||||
: workId === "l34b-nested-box-consolidation-shadow" ? results.l34b !== null
|
: workId === "l34b-nested-box-consolidation-shadow" ? results.l34b !== null
|
||||||
@@ -403,6 +409,8 @@ export async function fetchAdvancedLaboratoryResult(
|
|||||||
results.e46i = await fetchE46IGroundingDinoFullReplay({ fetcher, signal });
|
results.e46i = await fetchE46IGroundingDinoFullReplay({ fetcher, signal });
|
||||||
} else if (workId === "e46j-raw-fisheye-realtime") {
|
} else if (workId === "e46j-raw-fisheye-realtime") {
|
||||||
results.e46j = await fetchE46JRawFisheyeRealtime({ fetcher, signal });
|
results.e46j = await fetchE46JRawFisheyeRealtime({ fetcher, signal });
|
||||||
|
} else if (workId === "e47-semantic-slam-shadow") {
|
||||||
|
results.e47 = await fetchE47SemanticSlamResult({ fetcher, signal });
|
||||||
} else if (workId === "l34-right-yolox-truth-island-freeze") {
|
} else if (workId === "l34-right-yolox-truth-island-freeze") {
|
||||||
results.l34 = await fetchL34RightYoloxTruthIsland({ fetcher, signal });
|
results.l34 = await fetchL34RightYoloxTruthIsland({ fetcher, signal });
|
||||||
} else if (workId === "l34a-assisted-yolox-error-audit") {
|
} else if (workId === "l34a-assisted-yolox-error-audit") {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetector
|
|||||||
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
||||||
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
||||||
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||||
|
import type { E47SemanticSlamResult } from "./e47SemanticSlam";
|
||||||
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||||
|
|
||||||
export interface AdvancedLaboratoryResults {
|
export interface AdvancedLaboratoryResults {
|
||||||
@@ -59,6 +60,7 @@ export interface AdvancedLaboratoryResults {
|
|||||||
e46h: E46HFullRectifiedFrontReplayResult | null;
|
e46h: E46HFullRectifiedFrontReplayResult | null;
|
||||||
e46i: E46IGroundingDinoFullReplayResult | null;
|
e46i: E46IGroundingDinoFullReplayResult | null;
|
||||||
e46j: E46JRawFisheyeRealtimeResult | null;
|
e46j: E46JRawFisheyeRealtimeResult | null;
|
||||||
|
e47: E47SemanticSlamResult | null;
|
||||||
l34: L34RightYoloxTruthIslandResult | null;
|
l34: L34RightYoloxTruthIslandResult | null;
|
||||||
l34a: L34AAssistedYoloxErrorAuditResult | null;
|
l34a: L34AAssistedYoloxErrorAuditResult | null;
|
||||||
l34b: L34BResult | null;
|
l34b: L34BResult | null;
|
||||||
|
|||||||
@@ -985,9 +985,9 @@ export async function fetchAdvancedLaboratoryResults({
|
|||||||
e46b: null,
|
e46b: null,
|
||||||
e46c: null,
|
e46c: null,
|
||||||
e46d: null,
|
e46d: null,
|
||||||
e46e: null,
|
e46e: null, e46f: null,
|
||||||
e46f: null,
|
|
||||||
e46g: null, e46h: null, e46i: null, e46j: null,
|
e46g: null, e46h: null, e46i: null, e46j: null,
|
||||||
|
e47: null,
|
||||||
l34: null,
|
l34: null,
|
||||||
l34a: null,
|
l34a: null,
|
||||||
l34b: null,
|
l34b: null,
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
export type E47SemanticDisposition = "labeled" | "ambiguous";
|
||||||
|
|
||||||
|
export interface E47SemanticClass {
|
||||||
|
classId: number;
|
||||||
|
label: string;
|
||||||
|
disposition: E47SemanticDisposition;
|
||||||
|
colorRgb: readonly [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E47SemanticSlamResult {
|
||||||
|
resultId: string;
|
||||||
|
createdAtUtc: string;
|
||||||
|
status: "diagnostic-semantic-slam-shadow";
|
||||||
|
profileId: string;
|
||||||
|
baseM4ResultId: string;
|
||||||
|
semanticResultId: string;
|
||||||
|
geometryResultId: string;
|
||||||
|
sourcePackId: string;
|
||||||
|
calibrationContentSha256: string;
|
||||||
|
provider: {
|
||||||
|
providerId: string;
|
||||||
|
modelId: string;
|
||||||
|
modelRevision: string;
|
||||||
|
modelWeightsSha256: string;
|
||||||
|
preprocessId: string;
|
||||||
|
};
|
||||||
|
temporalBinding: {
|
||||||
|
semanticToCamera: "exact-sequence-and-session-time";
|
||||||
|
cameraToLidar: "accepted-e6-nearest-host-arrival-best-effort";
|
||||||
|
clockBasis: "recorded-host-monotonic-arrival";
|
||||||
|
maximumLidarCameraDeltaMs: number;
|
||||||
|
maximumPosePointDeltaMs: number;
|
||||||
|
physicalSynchronizationProven: false;
|
||||||
|
};
|
||||||
|
taxonomy: readonly E47SemanticClass[];
|
||||||
|
metrics: {
|
||||||
|
frames: {
|
||||||
|
total: number;
|
||||||
|
maskAvailable: number;
|
||||||
|
sourceAvailable: number;
|
||||||
|
};
|
||||||
|
points: {
|
||||||
|
total: number;
|
||||||
|
projected: number;
|
||||||
|
labeled: number;
|
||||||
|
ambiguous: number;
|
||||||
|
unprojected: number;
|
||||||
|
absent: number;
|
||||||
|
};
|
||||||
|
observations: {
|
||||||
|
total: number;
|
||||||
|
labeled: number;
|
||||||
|
ambiguous: number;
|
||||||
|
unprojected: number;
|
||||||
|
absent: number;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
elapsedMs: number;
|
||||||
|
framesPerSecond: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
acceptance: {
|
||||||
|
artifactContractPassed: boolean;
|
||||||
|
frameAccountingPassed: boolean;
|
||||||
|
pointAccountingPassed: boolean;
|
||||||
|
observationBindingPassed: boolean;
|
||||||
|
temporalBindingPassed: boolean;
|
||||||
|
independentSemanticTruthPassed: false;
|
||||||
|
providerPromoted: false;
|
||||||
|
};
|
||||||
|
limitations: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E47SemanticTimelineFrame {
|
||||||
|
sequence: number;
|
||||||
|
sourcePointCount: number;
|
||||||
|
classIds: readonly number[];
|
||||||
|
statusCodes: readonly number[];
|
||||||
|
counts: {
|
||||||
|
labeled: number;
|
||||||
|
ambiguous: number;
|
||||||
|
unprojected: number;
|
||||||
|
absent: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E47SemanticTimelineChunk {
|
||||||
|
resultId: string;
|
||||||
|
startSequence: number;
|
||||||
|
frameCount: number;
|
||||||
|
nextSequence: number | null;
|
||||||
|
frames: readonly E47SemanticTimelineFrame[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export class E47SemanticSlamContractError extends Error {}
|
||||||
|
|
||||||
|
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new E47SemanticSlamContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new E47SemanticSlamContractError(`${label}: ожидался массив.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = (value: unknown, label: string): string => {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new E47SemanticSlamContractError(`${label}: ожидалась строка.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finite = (value: unknown, label: string): number => {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new E47SemanticSlamContractError(`${label}: ожидалось число.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const integer = (value: unknown, label: string): number => {
|
||||||
|
const parsed = finite(value, label);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||||
|
throw new E47SemanticSlamContractError(`${label}: ожидалось неотрицательное целое.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exact = <T extends string | number | boolean>(
|
||||||
|
value: unknown,
|
||||||
|
expected: T,
|
||||||
|
label: string,
|
||||||
|
): T => {
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new E47SemanticSlamContractError(`${label}: нарушен контракт.`);
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resultId(value: unknown): string {
|
||||||
|
const parsed = text(value, "E47 result id");
|
||||||
|
if (!/^e47-semantic-slam-[a-f0-9]{64}$/.test(parsed)) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 result id: нарушена идентичность.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function m4ResultId(value: unknown): string {
|
||||||
|
const parsed = text(value, "E47 base M4 result id");
|
||||||
|
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 base M4 result id: нарушена идентичность.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value: unknown, label: string): string {
|
||||||
|
const parsed = text(value, label);
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||||
|
throw new E47SemanticSlamContractError(`${label}: ожидался SHA-256.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxonomy(value: unknown): readonly E47SemanticClass[] {
|
||||||
|
const classes = array(value, "E47 taxonomy").map((raw) => {
|
||||||
|
const item = object(raw, "E47 semantic class");
|
||||||
|
const classId = integer(item.class_id, "E47 class id");
|
||||||
|
if (classId > 255) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 class id: вышел за uint8.");
|
||||||
|
}
|
||||||
|
const disposition = text(item.disposition, "E47 class disposition");
|
||||||
|
if (disposition !== "labeled" && disposition !== "ambiguous") {
|
||||||
|
throw new E47SemanticSlamContractError("E47 class disposition: неизвестное значение.");
|
||||||
|
}
|
||||||
|
const rgb = array(item.color_rgb, "E47 class color").map(
|
||||||
|
(channel) => integer(channel, "E47 color channel"),
|
||||||
|
);
|
||||||
|
if (rgb.length !== 3 || rgb.some((channel) => channel > 255)) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 class color: нарушен RGB-контракт.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
label: text(item.label, "E47 class label"),
|
||||||
|
disposition: disposition as E47SemanticDisposition,
|
||||||
|
colorRgb: [rgb[0]!, rgb[1]!, rgb[2]!] as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!classes.length || new Set(classes.map((item) => item.classId)).size !== classes.length) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 taxonomy: классы отсутствуют или дублируются.");
|
||||||
|
}
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResult(value: unknown): E47SemanticSlamResult {
|
||||||
|
const item = object(value, "E47 result");
|
||||||
|
exact(item.schema_version, "missioncore.e47-semantic-slam-view/v1", "E47 view schema");
|
||||||
|
exact(item.status, "diagnostic-semantic-slam-shadow", "E47 status");
|
||||||
|
exact(item.ground_truth, false, "E47 ground truth");
|
||||||
|
exact(item.semantic_authority, "diagnostic-only", "E47 semantic authority");
|
||||||
|
exact(item.navigation_or_safety_accepted, false, "E47 safety authority");
|
||||||
|
exact(item.actuation_allowed, false, "E47 actuation authority");
|
||||||
|
const provider = object(item.provider, "E47 provider");
|
||||||
|
const temporalBinding = object(item.temporal_binding, "E47 temporal binding");
|
||||||
|
const metrics = object(item.metrics, "E47 metrics");
|
||||||
|
const frames = object(metrics.frames, "E47 frame metrics");
|
||||||
|
const points = object(metrics.points, "E47 point metrics");
|
||||||
|
const observations = object(metrics.observations, "E47 observation metrics");
|
||||||
|
const runtime = object(metrics.runtime, "E47 runtime metrics");
|
||||||
|
const acceptance = object(item.acceptance, "E47 acceptance");
|
||||||
|
const frameMetrics = {
|
||||||
|
total: exact(frames.total, 4489, "E47 frame total"),
|
||||||
|
maskAvailable: integer(frames.mask_available, "E47 mask frames"),
|
||||||
|
sourceAvailable: integer(frames.source_available, "E47 source frames"),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
frameMetrics.maskAvailable > frameMetrics.total
|
||||||
|
|| frameMetrics.sourceAvailable > frameMetrics.total
|
||||||
|
) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame accounting: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const pointMetrics = {
|
||||||
|
total: integer(points.total, "E47 total points"),
|
||||||
|
projected: integer(points.projected, "E47 projected points"),
|
||||||
|
labeled: integer(points.labeled, "E47 labeled points"),
|
||||||
|
ambiguous: integer(points.ambiguous, "E47 ambiguous points"),
|
||||||
|
unprojected: integer(points.unprojected, "E47 unprojected points"),
|
||||||
|
absent: integer(points.absent, "E47 absent points"),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
pointMetrics.projected !== pointMetrics.labeled + pointMetrics.ambiguous
|
||||||
|
|| pointMetrics.total !== pointMetrics.projected
|
||||||
|
+ pointMetrics.unprojected
|
||||||
|
+ pointMetrics.absent
|
||||||
|
) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 point accounting: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const observationMetrics = {
|
||||||
|
total: integer(observations.total, "E47 total observations"),
|
||||||
|
labeled: integer(observations.labeled, "E47 labeled observations"),
|
||||||
|
ambiguous: integer(observations.ambiguous, "E47 ambiguous observations"),
|
||||||
|
unprojected: integer(observations.unprojected, "E47 unprojected observations"),
|
||||||
|
absent: integer(observations.absent, "E47 absent observations"),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
observationMetrics.total !== observationMetrics.labeled
|
||||||
|
+ observationMetrics.ambiguous
|
||||||
|
+ observationMetrics.unprojected
|
||||||
|
+ observationMetrics.absent
|
||||||
|
) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 observation accounting: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const runtimeMetrics = {
|
||||||
|
elapsedMs: finite(runtime.elapsed_ms, "E47 elapsed"),
|
||||||
|
framesPerSecond: finite(runtime.frames_per_second, "E47 FPS"),
|
||||||
|
};
|
||||||
|
if (runtimeMetrics.elapsedMs <= 0 || runtimeMetrics.framesPerSecond <= 0) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 runtime accounting: нарушен контракт.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultId: resultId(item.result_id),
|
||||||
|
createdAtUtc: text(item.created_at_utc, "E47 created at"),
|
||||||
|
status: "diagnostic-semantic-slam-shadow",
|
||||||
|
profileId: text(item.profile_id, "E47 profile"),
|
||||||
|
baseM4ResultId: m4ResultId(item.base_m4_result_id),
|
||||||
|
semanticResultId: text(item.semantic_result_id, "E47 semantic source"),
|
||||||
|
geometryResultId: text(item.geometry_result_id, "E47 geometry source"),
|
||||||
|
sourcePackId: text(item.source_pack_id, "E47 source pack"),
|
||||||
|
calibrationContentSha256: sha256(item.calibration_content_sha256, "E47 calibration"),
|
||||||
|
provider: {
|
||||||
|
providerId: text(provider.provider_id, "E47 provider id"),
|
||||||
|
modelId: text(provider.model_id, "E47 model id"),
|
||||||
|
modelRevision: text(provider.model_revision, "E47 model revision"),
|
||||||
|
modelWeightsSha256: sha256(provider.model_weights_sha256, "E47 model weights"),
|
||||||
|
preprocessId: text(provider.preprocess_id, "E47 preprocess id"),
|
||||||
|
},
|
||||||
|
temporalBinding: {
|
||||||
|
semanticToCamera: exact(
|
||||||
|
temporalBinding.semantic_to_camera,
|
||||||
|
"exact-sequence-and-session-time",
|
||||||
|
"E47 semantic/camera binding",
|
||||||
|
),
|
||||||
|
cameraToLidar: exact(
|
||||||
|
temporalBinding.camera_to_lidar,
|
||||||
|
"accepted-e6-nearest-host-arrival-best-effort",
|
||||||
|
"E47 camera/LiDAR binding",
|
||||||
|
),
|
||||||
|
clockBasis: exact(
|
||||||
|
temporalBinding.clock_basis,
|
||||||
|
"recorded-host-monotonic-arrival",
|
||||||
|
"E47 clock basis",
|
||||||
|
),
|
||||||
|
maximumLidarCameraDeltaMs: finite(
|
||||||
|
temporalBinding.maximum_lidar_camera_delta_ms,
|
||||||
|
"E47 maximum camera/LiDAR delta",
|
||||||
|
),
|
||||||
|
maximumPosePointDeltaMs: finite(
|
||||||
|
temporalBinding.maximum_pose_point_delta_ms,
|
||||||
|
"E47 maximum pose/point delta",
|
||||||
|
),
|
||||||
|
physicalSynchronizationProven: exact(
|
||||||
|
temporalBinding.physical_synchronization_proven,
|
||||||
|
false,
|
||||||
|
"E47 physical synchronization",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
taxonomy: taxonomy(item.taxonomy),
|
||||||
|
metrics: {
|
||||||
|
frames: frameMetrics,
|
||||||
|
points: pointMetrics,
|
||||||
|
observations: observationMetrics,
|
||||||
|
runtime: runtimeMetrics,
|
||||||
|
},
|
||||||
|
acceptance: {
|
||||||
|
artifactContractPassed: exact(
|
||||||
|
acceptance.artifact_contract_passed,
|
||||||
|
true,
|
||||||
|
"E47 artifact contract",
|
||||||
|
),
|
||||||
|
frameAccountingPassed: exact(
|
||||||
|
acceptance.frame_accounting_passed,
|
||||||
|
true,
|
||||||
|
"E47 frame accounting",
|
||||||
|
),
|
||||||
|
pointAccountingPassed: exact(
|
||||||
|
acceptance.point_accounting_passed,
|
||||||
|
true,
|
||||||
|
"E47 point accounting",
|
||||||
|
),
|
||||||
|
observationBindingPassed: exact(
|
||||||
|
acceptance.observation_binding_passed,
|
||||||
|
true,
|
||||||
|
"E47 observation binding",
|
||||||
|
),
|
||||||
|
temporalBindingPassed: exact(
|
||||||
|
acceptance.temporal_binding_passed,
|
||||||
|
true,
|
||||||
|
"E47 temporal binding",
|
||||||
|
),
|
||||||
|
independentSemanticTruthPassed: exact(
|
||||||
|
acceptance.independent_semantic_truth_passed,
|
||||||
|
false,
|
||||||
|
"E47 independent truth",
|
||||||
|
),
|
||||||
|
providerPromoted: exact(
|
||||||
|
acceptance.provider_promoted,
|
||||||
|
false,
|
||||||
|
"E47 provider promotion",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
limitations: array(item.limitations, "E47 limitations").map(
|
||||||
|
(entry) => text(entry, "E47 limitation"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchE47SemanticSlamResult({
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {}): Promise<E47SemanticSlamResult | null> {
|
||||||
|
const response = await fetcher("/api/v1/laboratory/e47-semantic-slam/results?limit=1", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new E47SemanticSlamContractError(`E47 LAB недоступен: HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const payload = object(await response.json(), "E47 catalog");
|
||||||
|
exact(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.e47-semantic-slam-catalog/v1",
|
||||||
|
"E47 catalog schema",
|
||||||
|
);
|
||||||
|
const items = array(payload.items, "E47 catalog items");
|
||||||
|
return items.length ? parseResult(items[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrame(
|
||||||
|
value: unknown,
|
||||||
|
expectedSequence: number,
|
||||||
|
declaredTaxonomy: readonly E47SemanticClass[] | undefined,
|
||||||
|
): E47SemanticTimelineFrame {
|
||||||
|
const item = object(value, "E47 semantic frame");
|
||||||
|
exact(item.schema_version, "missioncore.e47-semantic-slam-frame/v1", "E47 frame schema");
|
||||||
|
const sequence = integer(item.sequence, "E47 frame sequence");
|
||||||
|
if (sequence !== expectedSequence) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame sequence: нарушен порядок.");
|
||||||
|
}
|
||||||
|
const sourcePointCount = integer(item.source_point_count, "E47 frame source points");
|
||||||
|
const classIds = array(item.class_ids, "E47 frame classes").map((entry) => {
|
||||||
|
const parsed = finite(entry, "E47 frame class");
|
||||||
|
if (!Number.isInteger(parsed) || parsed < -1 || parsed > 255) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame class: вышел за контракт.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
});
|
||||||
|
const statusCodes = array(item.status_codes, "E47 frame statuses").map((entry) => {
|
||||||
|
const parsed = integer(entry, "E47 frame status");
|
||||||
|
if (parsed > 3) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame status: неизвестное значение.");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
});
|
||||||
|
if (classIds.length !== sourcePointCount || statusCodes.length !== sourcePointCount) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame point accounting: нарушен контракт.");
|
||||||
|
}
|
||||||
|
if (classIds.some((classId, index) => {
|
||||||
|
const status = statusCodes[index];
|
||||||
|
return status === 0 || status === 1 ? classId !== -1 : classId < 0;
|
||||||
|
})) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame class/status binding: нарушен контракт.");
|
||||||
|
}
|
||||||
|
if (declaredTaxonomy) {
|
||||||
|
const classesById = new Map(declaredTaxonomy.map((item) => [item.classId, item]));
|
||||||
|
if (classIds.some((classId, index) => {
|
||||||
|
const status = statusCodes[index];
|
||||||
|
if (status !== 2 && status !== 3) return false;
|
||||||
|
const semanticClass = classesById.get(classId);
|
||||||
|
return !semanticClass
|
||||||
|
|| (status === 2 && semanticClass.disposition !== "ambiguous")
|
||||||
|
|| (status === 3 && semanticClass.disposition !== "labeled");
|
||||||
|
})) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame taxonomy binding: нарушен контракт.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const counts = object(item.counts, "E47 frame counts");
|
||||||
|
const parsedCounts = {
|
||||||
|
labeled: integer(counts.labeled, "E47 frame labeled"),
|
||||||
|
ambiguous: integer(counts.ambiguous, "E47 frame ambiguous"),
|
||||||
|
unprojected: integer(counts.unprojected, "E47 frame unprojected"),
|
||||||
|
absent: integer(counts.absent, "E47 frame absent"),
|
||||||
|
};
|
||||||
|
if (Object.values(parsedCounts).reduce((sum, count) => sum + count, 0) !== sourcePointCount) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame status accounting: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const actualCounts = {
|
||||||
|
labeled: statusCodes.filter((status) => status === 3).length,
|
||||||
|
ambiguous: statusCodes.filter((status) => status === 2).length,
|
||||||
|
unprojected: statusCodes.filter((status) => status === 1).length,
|
||||||
|
absent: statusCodes.filter((status) => status === 0).length,
|
||||||
|
};
|
||||||
|
if (Object.keys(actualCounts).some(
|
||||||
|
(key) => actualCounts[key as keyof typeof actualCounts]
|
||||||
|
!== parsedCounts[key as keyof typeof parsedCounts],
|
||||||
|
)) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 frame status histogram: нарушен контракт.");
|
||||||
|
}
|
||||||
|
return { sequence, sourcePointCount, classIds, statusCodes, counts: parsedCounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchE47SemanticTimelineChunk(
|
||||||
|
result: string,
|
||||||
|
startSequence: number,
|
||||||
|
frameCount: number,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
taxonomy,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
taxonomy?: readonly E47SemanticClass[];
|
||||||
|
} = {},
|
||||||
|
): Promise<E47SemanticTimelineChunk> {
|
||||||
|
resultId(result);
|
||||||
|
const parameters = new URLSearchParams({
|
||||||
|
start: String(startSequence),
|
||||||
|
count: String(frameCount),
|
||||||
|
});
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/e47-semantic-slam/results/${result}/timeline/chunk?${parameters}`,
|
||||||
|
{ headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new E47SemanticSlamContractError(`E47 timeline chunk: HTTP ${response.status}.`);
|
||||||
|
}
|
||||||
|
const payload = object(await response.json(), "E47 semantic chunk");
|
||||||
|
exact(payload.schema_version, "missioncore.e47-semantic-slam-chunk/v1", "E47 chunk schema");
|
||||||
|
exact(payload.result_id, result, "E47 chunk result");
|
||||||
|
const parsedStart = integer(payload.start_sequence, "E47 chunk start");
|
||||||
|
if (parsedStart !== startSequence) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 chunk start: нарушен контракт.");
|
||||||
|
}
|
||||||
|
const frames = array(payload.frames, "E47 chunk frames").map(
|
||||||
|
(frame, offset) => parseFrame(frame, parsedStart + offset, taxonomy),
|
||||||
|
);
|
||||||
|
const parsedCount = integer(payload.frame_count, "E47 chunk count");
|
||||||
|
if (parsedCount !== frames.length || parsedCount > frameCount) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 chunk frame count: нарушен контракт.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultId: result,
|
||||||
|
startSequence: parsedStart,
|
||||||
|
frameCount: parsedCount,
|
||||||
|
nextSequence: payload.next_sequence === null
|
||||||
|
? null
|
||||||
|
: integer(payload.next_sequence, "E47 next sequence"),
|
||||||
|
frames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function e47SemanticMaskUrl(result: string, sequence: number): string {
|
||||||
|
resultId(result);
|
||||||
|
if (!Number.isInteger(sequence) || sequence < 0 || sequence >= 4489) {
|
||||||
|
throw new E47SemanticSlamContractError("E47 mask sequence: вне recorded replay.");
|
||||||
|
}
|
||||||
|
return `/api/v1/laboratory/e47-semantic-slam/results/${result}/masks/${sequence}`;
|
||||||
|
}
|
||||||
@@ -88,6 +88,24 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recorded-evidence-semantic-mask-overlay__error {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
top: 4.9rem;
|
||||||
|
left: 50%;
|
||||||
|
max-width: min(32rem, calc(100% - 2rem));
|
||||||
|
border: 1px solid rgb(var(--nodedc-warning-rgb) / 0.44);
|
||||||
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
|
background: var(--nodedc-floating-surface);
|
||||||
|
padding: 0.42rem 0.58rem;
|
||||||
|
color: rgb(var(--nodedc-warning-rgb));
|
||||||
|
font-size: 0.54rem;
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__legend,
|
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__legend,
|
||||||
.m4-replay-threat-evidence-viewer .m4-replay-threat-visual__overlay {
|
.m4-replay-threat-evidence-viewer .m4-replay-threat-visual__overlay {
|
||||||
bottom: 6.2rem;
|
bottom: 6.2rem;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
|
|
||||||
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__controls {
|
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__controls {
|
||||||
width: calc(100% - 1.2rem);
|
width: calc(100% - 1.2rem);
|
||||||
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-evidence-viewer .l3-visual-audit__actions {
|
.m4-replay-threat-evidence-viewer .l3-visual-audit__actions {
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__layer-controls .nodedc-segmented__item {
|
.m4-replay-threat-visual__layer-controls .nodedc-segmented__item {
|
||||||
padding-inline: 0.72rem;
|
padding-inline: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__transport {
|
.m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__transport {
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
width: 0.38rem;
|
width: 0.38rem;
|
||||||
height: 0.38rem;
|
height: 0.38rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--nodedc-text-muted);
|
background: var(--laboratory-metric-legend-color, var(--nodedc-text-muted));
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { E46GRectifiedDetectorBakeoffResultView } from "./E46GRectifiedDetectorB
|
|||||||
import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult";
|
import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult";
|
||||||
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
|
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
|
||||||
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
|
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
|
||||||
|
import { E47SemanticSlamResultView } from "./E47SemanticSlamResult";
|
||||||
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
||||||
|
|
||||||
export { isAdvancedLaboratoryWorkId };
|
export { isAdvancedLaboratoryWorkId };
|
||||||
@@ -85,6 +86,9 @@ export function AdvancedLaboratoryResult({
|
|||||||
if (workId === "m4-replay-threat" && results.m4Threat) {
|
if (workId === "m4-replay-threat" && results.m4Threat) {
|
||||||
return <M4ReplayThreatResultView rigLabel={rigLabel} result={results.m4Threat} />;
|
return <M4ReplayThreatResultView rigLabel={rigLabel} result={results.m4Threat} />;
|
||||||
}
|
}
|
||||||
|
if (workId === "e47-semantic-slam-shadow" && results.e47) {
|
||||||
|
return <E47SemanticSlamResultView rigLabel={rigLabel} result={results.e47} />;
|
||||||
|
}
|
||||||
if (workId === "l3-pointpillars-visual-audit" && results.l3) {
|
if (workId === "l3-pointpillars-visual-audit" && results.l3) {
|
||||||
return <L3PointPillarsResult result={results.l3} />;
|
return <L3PointPillarsResult result={results.l3} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import {
|
||||||
|
LaboratoryEvidence,
|
||||||
|
LaboratoryResultSummary,
|
||||||
|
LaboratorySummary,
|
||||||
|
LaboratoryWorkTemplate,
|
||||||
|
} from "../../components/laboratory/LaboratoryPresentation";
|
||||||
|
import type { E47SemanticSlamResult } from "../../core/laboratory/e47SemanticSlam";
|
||||||
|
import { formatNumber } from "../../presentation";
|
||||||
|
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
||||||
|
|
||||||
|
export function E47SemanticSlamResultView({
|
||||||
|
rigLabel,
|
||||||
|
result,
|
||||||
|
}: {
|
||||||
|
rigLabel: string;
|
||||||
|
result: E47SemanticSlamResult;
|
||||||
|
}) {
|
||||||
|
const pointProjectedCoverage = result.metrics.points.total
|
||||||
|
? result.metrics.points.projected / result.metrics.points.total
|
||||||
|
: 0;
|
||||||
|
const pointLabeledCoverage = result.metrics.points.total
|
||||||
|
? result.metrics.points.labeled / result.metrics.points.total
|
||||||
|
: 0;
|
||||||
|
const observationLabeledCoverage = result.metrics.observations.total
|
||||||
|
? result.metrics.observations.labeled / result.metrics.observations.total
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<LaboratoryWorkTemplate
|
||||||
|
summary={(
|
||||||
|
<LaboratorySummary
|
||||||
|
title="E47 · semantic mask → KB4 → SLAM shadow"
|
||||||
|
description="Зафиксированные EoMT-маски проецируются заводской KB4-калибровкой на исходные точки SLAM/LiDAR и отдельно агрегируются по уже существующим геометрическим наблюдениям. Это диагностический слой: он не меняет occupancy, motion, threat или safe/unknown."
|
||||||
|
status="Diagnostic contract passed · provider quality gate open"
|
||||||
|
statusTone="warning"
|
||||||
|
facts={[
|
||||||
|
{
|
||||||
|
label: "Конфигурация",
|
||||||
|
value: `${rigLabel} · RIGHT camera + registered SLAM cloud · recorded replay`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Semantic control",
|
||||||
|
value: `${result.provider.modelId} · exact revision ${result.provider.modelRevision.slice(0, 12)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Проекция",
|
||||||
|
value: `factory KB4 · ${result.calibrationContentSha256.slice(0, 12)} · frame-local point IDs`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Синхрон",
|
||||||
|
value: `semantic↔camera exact ledger · camera↔LiDAR E6 best-effort ≤${formatNumber(result.temporalBinding.maximumLidarCameraDeltaMs, 0)} ms · HW sync: нет`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Покрытие",
|
||||||
|
value: `${result.metrics.frames.maskAvailable}/${result.metrics.frames.total} masks · ${formatNumber(pointProjectedCoverage * 100, 1)}% projected · ${formatNumber(pointLabeledCoverage * 100, 1)}% unambiguous`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Визуал",
|
||||||
|
value: "4489-frame VIDEO/CAMERA/3D/PLAN · один recorded clock · semantic layer",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
brief={{
|
||||||
|
question: "Можно ли добавить плотную семантику камеры к сильной SLAM/LiDAR-геометрии, не превратив классификацию в источник ложного свободного пространства?",
|
||||||
|
approach: "Для всех 4489 кадров переиспользованы неизменяемые EoMT masks, factory KB4 extrinsic/intrinsic и тот же source point index space, на котором построен M4.6. Semantic↔camera сверяется fail-closed по sequence и session-time; camera↔LiDAR сохраняет исходный bounded nearest-arrival E6 contract, а не выдаётся за hardware-sync. Каждая точка получает labeled, ambiguous, unprojected или absent.",
|
||||||
|
principalResult: `${result.metrics.points.labeled.toLocaleString("ru-RU")} точек получили однозначный класс, ${result.metrics.points.ambiguous.toLocaleString("ru-RU")} остались semantic-ambiguous, ${result.metrics.points.unprojected.toLocaleString("ru-RU")} не спроецировались. Из ${result.metrics.observations.total.toLocaleString("ru-RU")} неизменённых geometry observations однозначный класс получили ${formatNumber(observationLabeledCoverage * 100, 1)}%.`,
|
||||||
|
limitation: "EoMT здесь — фиксированный control provider, а не выбранная production-модель. Physical camera↔LiDAR hardware-sync не доказан; принят только E6 nearest-host-arrival best-effort в пределах 100 мс. Semantic/instance truth, obstacle recall и fisheye-specific качество независимо не размечены; отсутствие класса никогда не означает free.",
|
||||||
|
}}
|
||||||
|
method={{
|
||||||
|
completeness: "complete",
|
||||||
|
executionClass: "hybrid",
|
||||||
|
pipelineId: "semantic-slam-diagnostic-shadow/v1",
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
kind: "source",
|
||||||
|
name: result.semanticResultId,
|
||||||
|
version: result.provider.modelRevision,
|
||||||
|
role: "sealed full-route uint8 semantic masks",
|
||||||
|
identitySha256: result.provider.modelWeightsSha256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "source",
|
||||||
|
name: result.sourcePackId,
|
||||||
|
version: "registered map increments + vendor SLAM pose",
|
||||||
|
role: "точный frame-local point index space",
|
||||||
|
identitySha256: result.sourcePackId.split("-").at(-1) ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "source",
|
||||||
|
name: result.geometryResultId,
|
||||||
|
version: "immutable M4 geometry observations",
|
||||||
|
role: "неизменяемые obstacle IDs, occupancy и metric geometry",
|
||||||
|
identitySha256: result.geometryResultId.split("-").at(-1) ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "algorithm",
|
||||||
|
name: "semantic diagnostic fusion",
|
||||||
|
version: result.profileId,
|
||||||
|
role: "KB4 mask projection + point/observation accounting без safety authority",
|
||||||
|
identitySha256: result.resultId.split("-").at(-1) ?? null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
evidence={(
|
||||||
|
<LaboratoryEvidence
|
||||||
|
eyebrow="E47 VISUAL EVIDENCE · VIDEO / CAMERA / 3D / PLAN"
|
||||||
|
title="Синхронный контроль маски, semantic-точек, геометрии и коридора"
|
||||||
|
kind="diagnostic-model"
|
||||||
|
resizable
|
||||||
|
>
|
||||||
|
<M4ReplayThreatVisual
|
||||||
|
resultId={result.baseM4ResultId}
|
||||||
|
semantic={{
|
||||||
|
resultId: result.resultId,
|
||||||
|
taxonomy: result.taxonomy,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</LaboratoryEvidence>
|
||||||
|
)}
|
||||||
|
result={(
|
||||||
|
<LaboratoryResultSummary
|
||||||
|
title="Semantic/SLAM seam принят; качество provider ещё не принято"
|
||||||
|
status="Жёлтый: артефакты и проекция доказаны, independent semantic truth отсутствует"
|
||||||
|
statusTone="warning"
|
||||||
|
metrics={[
|
||||||
|
{
|
||||||
|
label: "Semantic masks",
|
||||||
|
value: `${result.metrics.frames.maskAvailable}/${result.metrics.frames.total}`,
|
||||||
|
hint: "exact immutable full-route archive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Point labels",
|
||||||
|
value: result.metrics.points.labeled.toLocaleString("ru-RU"),
|
||||||
|
hint: `${result.metrics.points.unprojected.toLocaleString("ru-RU")} unprojected · ${result.metrics.points.ambiguous.toLocaleString("ru-RU")} ambiguous`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Observation labels",
|
||||||
|
value: result.metrics.observations.labeled.toLocaleString("ru-RU"),
|
||||||
|
hint: `${result.metrics.observations.ambiguous.toLocaleString("ru-RU")} ambiguous`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Derivative build",
|
||||||
|
value: `${formatNumber(result.metrics.runtime.framesPerSecond, 1)} FPS`,
|
||||||
|
hint: `${formatNumber(result.metrics.runtime.elapsedMs / 1000, 1)} s offline`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
conclusion={{
|
||||||
|
proved: "Одна каноническая модель-независимая форма принимает sealed semantic mask, привязывает её к исходному кадру и к factory KB4, маркирует полный frame-local point space и публикует проверяемое semantic evidence для существующих geometry observations. Текущий M4.6 при этом не изменён.",
|
||||||
|
notProved: "Не доказаны physical hardware-sync, class accuracy, instance separation, удержание отдельных объектов, obstacle recall, перенос на другой маршрут/provider и production latency на Worker 006. Semantic evidence не имеет navigation/safety authority.",
|
||||||
|
decision: "Оставить EoMT как контрольную ветку. Следующий честный A/B — NVIDIA CitySemSegFormer на замороженном truth-island через тот же provider contract; после ручного GT сравнивать качество, а не интерфейс или цвет overlay.",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,11 +9,20 @@ import {
|
|||||||
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||||
import { RecordedEvidenceImageScene } from "../../components/laboratory/RecordedEvidenceImageScene";
|
import { RecordedEvidenceImageScene } from "../../components/laboratory/RecordedEvidenceImageScene";
|
||||||
|
import type {
|
||||||
|
RecordedEvidenceSemanticClass,
|
||||||
|
RecordedEvidenceSemanticOverlay,
|
||||||
|
RecordedEvidenceSemanticPaletteEntry,
|
||||||
|
} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay";
|
||||||
import {
|
import {
|
||||||
RecordedEvidenceVideoScene,
|
RecordedEvidenceVideoScene,
|
||||||
type RecordedEvidenceBox,
|
type RecordedEvidenceBox,
|
||||||
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||||
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
|
import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback";
|
||||||
|
import {
|
||||||
|
e47SemanticMaskUrl,
|
||||||
|
type E47SemanticClass,
|
||||||
|
} from "../../core/laboratory/e47SemanticSlam";
|
||||||
import type {
|
import type {
|
||||||
M4ThreatCameraProposal,
|
M4ThreatCameraProposal,
|
||||||
M4ThreatTimelineFrame,
|
M4ThreatTimelineFrame,
|
||||||
@@ -26,6 +35,7 @@ import {
|
|||||||
useM4ThreatTimelineFrame,
|
useM4ThreatTimelineFrame,
|
||||||
useM4ThreatTimelineMetadata,
|
useM4ThreatTimelineMetadata,
|
||||||
} from "./useM4ThreatTimeline";
|
} from "./useM4ThreatTimeline";
|
||||||
|
import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline";
|
||||||
|
|
||||||
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
||||||
|
|
||||||
@@ -65,12 +75,24 @@ function SpatialState({ message: text }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
export interface M4ReplayThreatSemanticLayer {
|
||||||
|
resultId: string;
|
||||||
|
taxonomy: readonly E47SemanticClass[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function M4ReplayThreatVisual({
|
||||||
|
resultId,
|
||||||
|
semantic,
|
||||||
|
}: {
|
||||||
|
resultId: string;
|
||||||
|
semantic?: M4ReplayThreatSemanticLayer;
|
||||||
|
}) {
|
||||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||||
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
const [spatialMode, setSpatialMode] = useState<LaboratoryMetricSceneMode>("3d");
|
||||||
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
const [showCurrentIncrement, setShowCurrentIncrement] = useState(true);
|
||||||
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
const [showLocalSurface, setShowLocalSurface] = useState(true);
|
||||||
const [showRollingMap, setShowRollingMap] = useState(true);
|
const [showRollingMap, setShowRollingMap] = useState(true);
|
||||||
|
const [showSemantic, setShowSemantic] = useState(true);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
const metricSceneRef = useRef<LaboratoryMetricEvidenceSceneHandle | null>(null);
|
||||||
const metadata = useM4ThreatTimelineMetadata(resultId);
|
const metadata = useM4ThreatTimelineMetadata(resultId);
|
||||||
@@ -140,6 +162,12 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
}, [resultId]);
|
}, [resultId]);
|
||||||
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame;
|
||||||
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
|
const frame = timelineFrame.activeFrame ?? lastFrameRef.current;
|
||||||
|
const semanticTimeline = useE47SemanticTimelineFrame({
|
||||||
|
resultId: semantic?.resultId ?? null,
|
||||||
|
activeSequence: frame?.sequence ?? timelineFrame.activeSequence,
|
||||||
|
frameCount: metadata.timeline?.frameCount ?? 0,
|
||||||
|
taxonomy: semantic?.taxonomy ?? [],
|
||||||
|
});
|
||||||
const displayingBufferedFrame = Boolean(
|
const displayingBufferedFrame = Boolean(
|
||||||
frame
|
frame
|
||||||
&& timelineFrame.activeSequence !== null
|
&& timelineFrame.activeSequence !== null
|
||||||
@@ -173,6 +201,57 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
pointLimit: 20_000,
|
pointLimit: 20_000,
|
||||||
},
|
},
|
||||||
), [frame, metadata.timeline, timelineFrame.availableFrames]);
|
), [frame, metadata.timeline, timelineFrame.availableFrames]);
|
||||||
|
const semanticClasses = useMemo<readonly RecordedEvidenceSemanticClass[]>(
|
||||||
|
() => semantic?.taxonomy.map((item) => ({
|
||||||
|
id: item.classId,
|
||||||
|
label: `semantic: ${item.label}`,
|
||||||
|
})) ?? [],
|
||||||
|
[semantic?.taxonomy],
|
||||||
|
);
|
||||||
|
const semanticPalette = useMemo<readonly RecordedEvidenceSemanticPaletteEntry[]>(
|
||||||
|
() => semantic?.taxonomy.map((item) => ({
|
||||||
|
classId: item.classId,
|
||||||
|
color: item.disposition === "ambiguous"
|
||||||
|
? { kind: "token" as const, token: "--nodedc-warning-rgb" as const }
|
||||||
|
: { kind: "diagnostic" as const, rgb: item.colorRgb },
|
||||||
|
opacity: item.disposition === "ambiguous" ? 0.22 : 0.56,
|
||||||
|
})) ?? [],
|
||||||
|
[semantic?.taxonomy],
|
||||||
|
);
|
||||||
|
const semanticFrame = semanticTimeline.activeFrame?.sequence === frame?.sequence
|
||||||
|
? semanticTimeline.activeFrame
|
||||||
|
: null;
|
||||||
|
const semanticIntegrityError = semantic && frame?.spatialAvailable && semanticFrame && (
|
||||||
|
semanticFrame.sourcePointCount !== frame.pointCloudSourceCount
|
||||||
|
|| frame.pointCloudSampleCount !== frame.pointCloudSourceCount
|
||||||
|
|| frame.pointCloudBodyXyzM.length !== frame.pointCloudSourceCount
|
||||||
|
)
|
||||||
|
? "E47 semantic point index space не совпал с exact current increment M4.6."
|
||||||
|
: null;
|
||||||
|
const alignedSemanticPointIds = useMemo<readonly (number | null)[] | undefined>(() => {
|
||||||
|
if (
|
||||||
|
!semantic
|
||||||
|
|| !showSemantic
|
||||||
|
|| !frame
|
||||||
|
|| !frame.spatialAvailable
|
||||||
|
|| !semanticFrame
|
||||||
|
|| semanticIntegrityError
|
||||||
|
) return undefined;
|
||||||
|
return semanticFrame.classIds.map((classId, index) => {
|
||||||
|
const status = semanticFrame.statusCodes[index];
|
||||||
|
return status === 2 || status === 3 ? classId : null;
|
||||||
|
});
|
||||||
|
}, [frame, semantic, semanticFrame, semanticIntegrityError, showSemantic]);
|
||||||
|
const semanticOverlay: RecordedEvidenceSemanticOverlay | undefined =
|
||||||
|
semantic && showSemantic && frame
|
||||||
|
? {
|
||||||
|
src: e47SemanticMaskUrl(semantic.resultId, frame.sequence),
|
||||||
|
classes: semanticClasses,
|
||||||
|
palette: semanticPalette,
|
||||||
|
opacity: 0.48,
|
||||||
|
ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const seek = (seconds: number) => playbackController.seek(seconds);
|
const seek = (seconds: number) => playbackController.seek(seconds);
|
||||||
const handleModeChange = (next: M4ThreatViewMode) => {
|
const handleModeChange = (next: M4ThreatViewMode) => {
|
||||||
@@ -205,40 +284,55 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
<Icon name="chevron-right" size={16} />
|
<Icon name="chevron-right" size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{mode === "3d" || mode === "plan" ? (
|
{mode === "3d" || mode === "plan" || semantic ? (
|
||||||
<div
|
<div
|
||||||
className="nodedc-segmented m4-replay-threat-visual__layer-controls"
|
className="nodedc-segmented m4-replay-threat-visual__layer-controls"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Слои пространственного evidence"
|
aria-label="Слои пространственного evidence"
|
||||||
>
|
>
|
||||||
<button
|
{mode === "3d" || mode === "plan" ? (
|
||||||
type="button"
|
<>
|
||||||
className="nodedc-segmented__item"
|
<button
|
||||||
data-active={showCurrentIncrement ? "true" : undefined}
|
type="button"
|
||||||
aria-pressed={showCurrentIncrement}
|
className="nodedc-segmented__item"
|
||||||
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
data-active={showCurrentIncrement ? "true" : undefined}
|
||||||
>
|
aria-pressed={showCurrentIncrement}
|
||||||
CURRENT
|
onClick={() => setShowCurrentIncrement((visible) => !visible)}
|
||||||
</button>
|
>
|
||||||
<button
|
CURRENT
|
||||||
type="button"
|
</button>
|
||||||
className="nodedc-segmented__item"
|
<button
|
||||||
data-active={showLocalSurface ? "true" : undefined}
|
type="button"
|
||||||
aria-pressed={showLocalSurface}
|
className="nodedc-segmented__item"
|
||||||
title="Bounded local SLAM surface · visual-derived"
|
data-active={showLocalSurface ? "true" : undefined}
|
||||||
onClick={() => setShowLocalSurface((visible) => !visible)}
|
aria-pressed={showLocalSurface}
|
||||||
>
|
title="Bounded local SLAM surface · visual-derived"
|
||||||
LOCAL SLAM
|
onClick={() => setShowLocalSurface((visible) => !visible)}
|
||||||
</button>
|
>
|
||||||
<button
|
LOCAL SLAM
|
||||||
type="button"
|
</button>
|
||||||
className="nodedc-segmented__item"
|
<button
|
||||||
data-active={showRollingMap ? "true" : undefined}
|
type="button"
|
||||||
aria-pressed={showRollingMap}
|
className="nodedc-segmented__item"
|
||||||
onClick={() => setShowRollingMap((visible) => !visible)}
|
data-active={showRollingMap ? "true" : undefined}
|
||||||
>
|
aria-pressed={showRollingMap}
|
||||||
ROLLING
|
onClick={() => setShowRollingMap((visible) => !visible)}
|
||||||
</button>
|
>
|
||||||
|
ROLLING
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{semantic ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodedc-segmented__item"
|
||||||
|
data-active={showSemantic ? "true" : undefined}
|
||||||
|
aria-pressed={showSemantic}
|
||||||
|
onClick={() => setShowSemantic((visible) => !visible)}
|
||||||
|
>
|
||||||
|
SEMANTICS
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +368,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
{frame.spatialAvailable
|
{frame.spatialAvailable
|
||||||
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames`
|
||||||
: "body frame / current increment unavailable"}
|
: "body frame / current increment unavailable"}
|
||||||
|
{semantic && semanticFrame
|
||||||
|
? ` · semantic L ${semanticFrame.counts.labeled} · A ${semanticFrame.counts.ambiguous} · U ${semanticFrame.counts.unprojected} · Ø ${semanticFrame.counts.absent}`
|
||||||
|
: semantic ? " · semantic buffer" : ""}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -314,6 +411,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
imageWidth={timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
|
semanticOverlay={mode === "video" ? semanticOverlay : undefined}
|
||||||
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
/>
|
/>
|
||||||
@@ -337,6 +435,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
imageWidth={timeline.imageWidth}
|
imageWidth={timeline.imageWidth}
|
||||||
imageHeight={timeline.imageHeight}
|
imageHeight={timeline.imageHeight}
|
||||||
boxes={activeBoxes}
|
boxes={activeBoxes}
|
||||||
|
semanticOverlay={mode === "camera" ? semanticOverlay : undefined}
|
||||||
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -360,6 +459,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
showCurrentIncrement={showCurrentIncrement}
|
showCurrentIncrement={showCurrentIncrement}
|
||||||
showLocalSurface={showLocalSurface}
|
showLocalSurface={showLocalSurface}
|
||||||
showRollingMap={showRollingMap}
|
showRollingMap={showRollingMap}
|
||||||
|
pointSemanticClassIds={alignedSemanticPointIds}
|
||||||
|
semanticClasses={semanticClasses}
|
||||||
|
semanticPalette={semanticPalette}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -375,6 +477,24 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
<span>{timelineFrame.error}</span>
|
<span>{timelineFrame.error}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{semantic && semanticTimeline.loading ? (
|
||||||
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<span>Догружаем semantic-point evidence E47</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{semanticTimeline.error ? (
|
||||||
|
<div className="m4-replay-threat-visual__buffering" role="alert">
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<span>{semanticTimeline.error}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{semanticIntegrityError ? (
|
||||||
|
<div className="m4-replay-threat-visual__buffering" role="alert">
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<span>{semanticIntegrityError}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
|
{frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
|
||||||
<div className="m4-replay-threat-visual__buffering" role="status">
|
<div className="m4-replay-threat-visual__buffering" role="status">
|
||||||
На этом кадре нет квалифицированного body frame; сцена сохранена.
|
На этом кадре нет квалифицированного body frame; сцена сохранена.
|
||||||
@@ -409,7 +529,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||||
<LaboratoryEvidenceViewer
|
<LaboratoryEvidenceViewer
|
||||||
label="M4.6 dual-evidence recorded-realtime replay"
|
label={semantic
|
||||||
|
? "E47 semantic + SLAM diagnostic replay"
|
||||||
|
: "M4.6 dual-evidence recorded-realtime replay"}
|
||||||
className="m4-replay-threat-evidence-viewer"
|
className="m4-replay-threat-evidence-viewer"
|
||||||
mode={mode}
|
mode={mode}
|
||||||
modes={[
|
modes={[
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`
|
|||||||
experimentName: "RAVNOVES00 dual-evidence threat qualification",
|
experimentName: "RAVNOVES00 dual-evidence threat qualification",
|
||||||
variantName: "M4.6 · virtual corridor replay · VIDEO/CAMERA/3D",
|
variantName: "M4.6 · virtual corridor replay · VIDEO/CAMERA/3D",
|
||||||
},
|
},
|
||||||
|
"e47-semantic-slam-shadow": {
|
||||||
|
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||||
|
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
|
||||||
|
experimentId: "ravnoves00-semantic-slam-shadow-r1",
|
||||||
|
experimentName: "RAVNOVES00 semantic mask → KB4 → SLAM diagnostic shadow",
|
||||||
|
variantName: "E47 · EoMT control · full semantic point projection",
|
||||||
|
},
|
||||||
"e28-local-surface": {
|
"e28-local-surface": {
|
||||||
profileId: "rig-camera-local-surface-v1",
|
profileId: "rig-camera-local-surface-v1",
|
||||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function mergeResults(
|
|||||||
e46h: next.e46h ?? current.e46h,
|
e46h: next.e46h ?? current.e46h,
|
||||||
e46i: next.e46i ?? current.e46i,
|
e46i: next.e46i ?? current.e46i,
|
||||||
e46j: next.e46j ?? current.e46j,
|
e46j: next.e46j ?? current.e46j,
|
||||||
|
e47: next.e47 ?? current.e47,
|
||||||
l34: next.l34 ?? current.l34,
|
l34: next.l34 ?? current.l34,
|
||||||
l34a: next.l34a ?? current.l34a,
|
l34a: next.l34a ?? current.l34a,
|
||||||
l34b: next.l34b ?? current.l34b,
|
l34b: next.l34b ?? current.l34b,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchE47SemanticTimelineChunk,
|
||||||
|
type E47SemanticClass,
|
||||||
|
type E47SemanticTimelineChunk,
|
||||||
|
type E47SemanticTimelineFrame,
|
||||||
|
} from "../../core/laboratory/e47SemanticSlam";
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 24;
|
||||||
|
const RETAINED_CHUNK_COUNT = 8;
|
||||||
|
const PREFETCH_CHUNKS_AHEAD = 2;
|
||||||
|
|
||||||
|
function chunkWindowStarts(activeStart: number, frameCount: number): readonly number[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: PREFETCH_CHUNKS_AHEAD + 2 },
|
||||||
|
(_, index) => activeStart + (index - 1) * CHUNK_SIZE,
|
||||||
|
).filter((start) => start >= 0 && start < frameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.message.trim()
|
||||||
|
? error.message
|
||||||
|
: "Semantic point evidence E47 недоступен.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useE47SemanticTimelineFrame({
|
||||||
|
resultId,
|
||||||
|
activeSequence,
|
||||||
|
frameCount,
|
||||||
|
taxonomy,
|
||||||
|
}: {
|
||||||
|
resultId: string | null;
|
||||||
|
activeSequence: number | null;
|
||||||
|
frameCount: number;
|
||||||
|
taxonomy: readonly E47SemanticClass[];
|
||||||
|
}) {
|
||||||
|
const [chunks, setChunks] = useState<ReadonlyMap<number, E47SemanticTimelineChunk>>(
|
||||||
|
() => new Map(),
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const chunksRef = useRef(chunks);
|
||||||
|
const inFlight = useRef(new Map<number, AbortController>());
|
||||||
|
const activeStartRef = useRef<number | null>(null);
|
||||||
|
chunksRef.current = chunks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
|
inFlight.current.clear();
|
||||||
|
const empty = new Map<number, E47SemanticTimelineChunk>();
|
||||||
|
chunksRef.current = empty;
|
||||||
|
setChunks(empty);
|
||||||
|
setError(null);
|
||||||
|
return () => {
|
||||||
|
for (const controller of inFlight.current.values()) controller.abort();
|
||||||
|
inFlight.current.clear();
|
||||||
|
};
|
||||||
|
}, [resultId]);
|
||||||
|
|
||||||
|
const activeStart = activeSequence === null
|
||||||
|
? null
|
||||||
|
: Math.floor(activeSequence / CHUNK_SIZE) * CHUNK_SIZE;
|
||||||
|
activeStartRef.current = activeStart;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resultId || activeStart === null || frameCount < 1) return;
|
||||||
|
for (const start of chunkWindowStarts(activeStart, frameCount)) {
|
||||||
|
if (chunksRef.current.has(start) || inFlight.current.has(start)) continue;
|
||||||
|
const controller = new AbortController();
|
||||||
|
inFlight.current.set(start, controller);
|
||||||
|
void fetchE47SemanticTimelineChunk(resultId, start, CHUNK_SIZE, {
|
||||||
|
signal: controller.signal,
|
||||||
|
taxonomy,
|
||||||
|
})
|
||||||
|
.then((chunk) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setChunks((current) => {
|
||||||
|
const next = new Map(current);
|
||||||
|
next.set(start, chunk);
|
||||||
|
const center = activeStartRef.current ?? start;
|
||||||
|
const retained = [...next.keys()]
|
||||||
|
.sort((left, right) => Math.abs(left - center) - Math.abs(right - center))
|
||||||
|
.slice(0, RETAINED_CHUNK_COUNT);
|
||||||
|
const bounded = new Map(retained.map((key) => [key, next.get(key)!]));
|
||||||
|
chunksRef.current = bounded;
|
||||||
|
return bounded;
|
||||||
|
});
|
||||||
|
if (start === activeStartRef.current) setError(null);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (!controller.signal.aborted && start === activeStartRef.current) {
|
||||||
|
setError(errorMessage(caught));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (inFlight.current.get(start) === controller) inFlight.current.delete(start);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeStart, frameCount, resultId, taxonomy]);
|
||||||
|
|
||||||
|
const activeFrame: E47SemanticTimelineFrame | null = useMemo(() => {
|
||||||
|
if (activeSequence === null || activeStart === null) return null;
|
||||||
|
return chunks.get(activeStart)?.frames.find(
|
||||||
|
(frame) => frame.sequence === activeSequence,
|
||||||
|
) ?? null;
|
||||||
|
}, [activeSequence, activeStart, chunks]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeFrame,
|
||||||
|
loading: Boolean(resultId) && activeSequence !== null && !activeFrame && !error,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let fetchE47SemanticSlamResult;
|
||||||
|
let fetchE47SemanticTimelineChunk;
|
||||||
|
let e47SemanticMaskUrl;
|
||||||
|
|
||||||
|
const resultId = `e47-semantic-slam-${"a".repeat(64)}`;
|
||||||
|
const baseM4ResultId = `m4-threat-replay-${"b".repeat(64)}`;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
fetchE47SemanticSlamResult,
|
||||||
|
fetchE47SemanticTimelineChunk,
|
||||||
|
e47SemanticMaskUrl,
|
||||||
|
} = await server.ssrLoadModule("/src/core/laboratory/e47SemanticSlam.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resultView(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.e47-semantic-slam-view/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
created_at_utc: "2026-08-06T10:00:00.000Z",
|
||||||
|
status: "diagnostic-semantic-slam-shadow",
|
||||||
|
profile_id: "ravnoves00-eomt-kb4-slam-shadow/v1",
|
||||||
|
base_m4_result_id: baseM4ResultId,
|
||||||
|
semantic_result_id: `result-${"c".repeat(64)}`,
|
||||||
|
geometry_result_id: `m4-geometry-replay-${"d".repeat(64)}`,
|
||||||
|
source_pack_id: `e10-lidar-pack-${"e".repeat(64)}`,
|
||||||
|
calibration_content_sha256: "f".repeat(64),
|
||||||
|
provider: {
|
||||||
|
provider_id: "eomt-cityscapes-semantic-control/v1",
|
||||||
|
model_id: "tue-mps/cityscapes_semantic_eomt_large_1024",
|
||||||
|
model_revision: "revision-1",
|
||||||
|
model_weights_sha256: "1".repeat(64),
|
||||||
|
preprocess_id: "raw-kb4-valid-fov-semantic/v1",
|
||||||
|
},
|
||||||
|
temporal_binding: {
|
||||||
|
semantic_to_camera: "exact-sequence-and-session-time",
|
||||||
|
camera_to_lidar: "accepted-e6-nearest-host-arrival-best-effort",
|
||||||
|
clock_basis: "recorded-host-monotonic-arrival",
|
||||||
|
maximum_lidar_camera_delta_ms: 100,
|
||||||
|
maximum_pose_point_delta_ms: 100,
|
||||||
|
physical_synchronization_proven: false,
|
||||||
|
},
|
||||||
|
taxonomy: [
|
||||||
|
{
|
||||||
|
class_id: 0,
|
||||||
|
label: "outside_valid_fov",
|
||||||
|
disposition: "ambiguous",
|
||||||
|
color_rgb: [0, 0, 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
class_id: 7,
|
||||||
|
label: "paved_road",
|
||||||
|
disposition: "labeled",
|
||||||
|
color_rgb: [128, 64, 128],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metrics: {
|
||||||
|
frames: { total: 4489, mask_available: 4489, source_available: 4489 },
|
||||||
|
points: {
|
||||||
|
total: 4,
|
||||||
|
projected: 2,
|
||||||
|
labeled: 1,
|
||||||
|
ambiguous: 1,
|
||||||
|
unprojected: 2,
|
||||||
|
absent: 0,
|
||||||
|
},
|
||||||
|
observations: {
|
||||||
|
total: 2,
|
||||||
|
labeled: 1,
|
||||||
|
ambiguous: 0,
|
||||||
|
unprojected: 1,
|
||||||
|
absent: 0,
|
||||||
|
},
|
||||||
|
runtime: { elapsed_ms: 1000, frames_per_second: 4.489 },
|
||||||
|
},
|
||||||
|
acceptance: {
|
||||||
|
artifact_contract_passed: true,
|
||||||
|
frame_accounting_passed: true,
|
||||||
|
point_accounting_passed: true,
|
||||||
|
observation_binding_passed: true,
|
||||||
|
temporal_binding_passed: true,
|
||||||
|
independent_semantic_truth_passed: false,
|
||||||
|
provider_promoted: false,
|
||||||
|
},
|
||||||
|
limitations: ["diagnostic only"],
|
||||||
|
ground_truth: false,
|
||||||
|
semantic_authority: "diagnostic-only",
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
actuation_allowed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("E47 accepts only a fully accounted diagnostic semantic/SLAM view", async () => {
|
||||||
|
const result = await fetchE47SemanticSlamResult({
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.e47-semantic-slam-catalog/v1",
|
||||||
|
items: [resultView()],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
assert.equal(result.resultId, resultId);
|
||||||
|
assert.equal(result.baseM4ResultId, baseM4ResultId);
|
||||||
|
assert.equal(result.metrics.points.projected, 2);
|
||||||
|
assert.equal(result.acceptance.independentSemanticTruthPassed, false);
|
||||||
|
assert.equal(result.temporalBinding.physicalSynchronizationProven, false);
|
||||||
|
assert.equal(result.temporalBinding.maximumLidarCameraDeltaMs, 100);
|
||||||
|
assert.equal(result.acceptance.temporalBindingPassed, true);
|
||||||
|
assert.equal(result.taxonomy[0].disposition, "ambiguous");
|
||||||
|
assert.equal(
|
||||||
|
e47SemanticMaskUrl(resultId, 14),
|
||||||
|
`/api/v1/laboratory/e47-semantic-slam/results/${resultId}/masks/14`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E47 rejects result-level point accounting drift", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
fetchE47SemanticSlamResult({
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.e47-semantic-slam-catalog/v1",
|
||||||
|
items: [resultView({
|
||||||
|
metrics: {
|
||||||
|
...resultView().metrics,
|
||||||
|
points: {
|
||||||
|
...resultView().metrics.points,
|
||||||
|
total: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
/point accounting/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E47 chunk preserves unavailable sentinel and verifies the status histogram", async () => {
|
||||||
|
const validFrame = {
|
||||||
|
schema_version: "missioncore.e47-semantic-slam-frame/v1",
|
||||||
|
sequence: 14,
|
||||||
|
source_point_count: 4,
|
||||||
|
class_ids: [7, 0, -1, -1],
|
||||||
|
status_codes: [3, 2, 1, 1],
|
||||||
|
counts: { labeled: 1, ambiguous: 1, unprojected: 2, absent: 0 },
|
||||||
|
};
|
||||||
|
const chunk = await fetchE47SemanticTimelineChunk(resultId, 14, 1, {
|
||||||
|
taxonomy: [
|
||||||
|
{ classId: 0, label: "outside_valid_fov", disposition: "ambiguous", colorRgb: [0, 0, 0] },
|
||||||
|
{ classId: 7, label: "paved_road", disposition: "labeled", colorRgb: [128, 64, 128] },
|
||||||
|
],
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.e47-semantic-slam-chunk/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
start_sequence: 14,
|
||||||
|
frame_count: 1,
|
||||||
|
next_sequence: 15,
|
||||||
|
frames: [validFrame],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
assert.deepEqual(chunk.frames[0].classIds, [7, 0, -1, -1]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
fetchE47SemanticTimelineChunk(resultId, 14, 1, {
|
||||||
|
taxonomy: [
|
||||||
|
{ classId: 0, label: "outside_valid_fov", disposition: "ambiguous", colorRgb: [0, 0, 0] },
|
||||||
|
{ classId: 7, label: "paved_road", disposition: "labeled", colorRgb: [128, 64, 128] },
|
||||||
|
],
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.e47-semantic-slam-chunk/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
start_sequence: 14,
|
||||||
|
frame_count: 1,
|
||||||
|
next_sequence: 15,
|
||||||
|
frames: [{ ...validFrame, class_ids: [7, 0, 7, -1] }],
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
/class\/status binding/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
const component = (name) => new URL(`../src/components/laboratory/${name}`, import.meta.url);
|
||||||
|
|
||||||
|
test("semantic evidence mask is decoded, cached, cancelled and object-contained client-side", async () => {
|
||||||
|
const source = await readFile(component("RecordedEvidenceSemanticMaskOverlay.tsx"), "utf8");
|
||||||
|
assert.match(source, /decodedMaskCache/);
|
||||||
|
assert.match(source, /pendingMaskCache/);
|
||||||
|
assert.match(source, /AbortController/);
|
||||||
|
assert.match(source, /getImageData/);
|
||||||
|
assert.match(source, /8-bit grayscale class-id PNG/);
|
||||||
|
assert.match(source, /ResizeObserver/);
|
||||||
|
assert.match(source, /Math\.min\(width \/ imageWidth, height \/ imageHeight\)/);
|
||||||
|
assert.match(source, /decoded\.key !== expectedKey/);
|
||||||
|
assert.match(source, /mask\?\.key === expectedKey/);
|
||||||
|
assert.match(source, /необъявленный class ID/);
|
||||||
|
assert.match(source, /recorded-evidence-semantic-mask-overlay__error/);
|
||||||
|
assert.match(source, /role="alert"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shared recorded scenes place optional semantic masks beneath boxes", async () => {
|
||||||
|
const [imageScene, videoScene] = await Promise.all([
|
||||||
|
readFile(component("RecordedEvidenceImageScene.tsx"), "utf8"),
|
||||||
|
readFile(component("RecordedEvidenceVideoScene.tsx"), "utf8"),
|
||||||
|
]);
|
||||||
|
for (const source of [imageScene, videoScene]) {
|
||||||
|
assert.match(source, /semanticOverlay\?: RecordedEvidenceSemanticOverlay/);
|
||||||
|
assert.ok(
|
||||||
|
source.indexOf("<RecordedEvidenceSemanticMaskOverlay")
|
||||||
|
< source.indexOf("<RecordedEvidenceBoxOverlay"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("metric evidence keeps missing semantic assignments as context and exposes a taxonomy legend", async () => {
|
||||||
|
const source = await readFile(component("LaboratoryMetricEvidenceScene.tsx"), "utf8");
|
||||||
|
assert.match(source, /pointSemanticClassIds\?: readonly \(number \| null\)\[\]/);
|
||||||
|
assert.match(source, /pointSemanticClassIds\.length === pointCloudBodyXyzM\.length/);
|
||||||
|
assert.match(source, /classId === null \? undefined : colorsByClassId\.get\(classId\)/);
|
||||||
|
assert.match(source, /data-decision="semantic"/);
|
||||||
|
assert.match(source, /recordedEvidenceSemanticCssColor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("semantic point alignment is enforced only when M4 exposes the exact spatial increment", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /semantic && frame\?\.spatialAvailable && semanticFrame/);
|
||||||
|
assert.match(source, /\|\| !frame\.spatialAvailable\s*\|\| !semanticFrame/);
|
||||||
|
assert.match(source, /semanticFrame\.sourcePointCount !== frame\.pointCloudSourceCount/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("M4 mounts semantic mask overlays only for the active VIDEO or CAMERA layer", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/semanticOverlay=\{mode === "video" \? semanticOverlay : undefined\}/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/semanticOverlay=\{mode === "camera" \? semanticOverlay : undefined\}/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user