From 81ae4425cbb5f9e6847a6f15b45054262da92437 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 6 Aug 2026 11:26:46 +0300 Subject: [PATCH] feat(lab): visualize semantic SLAM shadow --- .../LaboratoryMetricEvidenceScene.tsx | 79 ++- .../laboratory/RecordedEvidenceImageScene.tsx | 27 +- .../RecordedEvidenceSemanticMaskOverlay.tsx | 391 +++++++++++++ .../laboratory/RecordedEvidenceVideoScene.tsx | 13 + .../src/core/laboratory/advancedIndex.ts | 8 + .../laboratory/advancedLaboratoryResults.ts | 2 + .../src/core/laboratory/advancedResults.ts | 4 +- .../src/core/laboratory/e47SemanticSlam.ts | 516 ++++++++++++++++++ .../styles/l3-pointpillars-visual-audit.css | 18 + .../src/styles/m4-replay-threat.css | 5 +- .../laboratory/AdvancedLaboratoryResult.tsx | 4 + .../laboratory/E47SemanticSlamResult.tsx | 156 ++++++ .../laboratory/M4ReplayThreatVisual.tsx | 184 +++++-- .../laboratory/laboratoryArchiveProfiles.ts | 7 + .../useAdvancedLaboratoryCatalog.ts | 1 + .../laboratory/useE47SemanticTimeline.ts | 113 ++++ .../test/e47SemanticSlam.test.mjs | 192 +++++++ .../test/semanticEvidencePrimitives.test.mjs | 69 +++ 18 files changed, 1747 insertions(+), 42 deletions(-) create mode 100644 apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx create mode 100644 apps/control-station/src/core/laboratory/e47SemanticSlam.ts create mode 100644 apps/control-station/src/workspaces/laboratory/E47SemanticSlamResult.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts create mode 100644 apps/control-station/test/e47SemanticSlam.test.mjs create mode 100644 apps/control-station/test/semanticEvidencePrimitives.test.mjs diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index aa32ed2..fea30f1 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -1,5 +1,6 @@ import { forwardRef, + type CSSProperties, useEffect, useImperativeHandle, useRef, @@ -8,6 +9,13 @@ import { import * as THREE from "three"; 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 LaboratoryMetricDecision = "threat" | "not-threat" | "unknown"; export type LaboratoryMetricSceneMode = "3d" | "plan"; @@ -114,6 +122,9 @@ LaboratoryMetricEvidenceSceneHandle, showCurrentIncrement: boolean; showLocalSurface: boolean; showRollingMap: boolean; + pointSemanticClassIds?: readonly (number | null)[]; + semanticClasses?: readonly RecordedEvidenceSemanticClass[]; + semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[]; } >(function LaboratoryMetricEvidenceScene({ pointCloudBodyXyzM, @@ -127,6 +138,9 @@ LaboratoryMetricEvidenceSceneHandle, showCurrentIncrement, showLocalSurface, showRollingMap, + pointSemanticClassIds, + semanticClasses, + semanticPalette, }, ref) { const hostRef = useRef(null); const sceneRef = useRef(null); @@ -240,10 +254,40 @@ LaboratoryMetricEvidenceSceneHandle, "position", 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(); + 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( contextGeometry, 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, sizeAttenuation: false, transparent: true, @@ -321,6 +365,9 @@ LaboratoryMetricEvidenceSceneHandle, occupiedVoxelSizeM, localSurfaceBodyXyzM, pointCloudBodyXyzM, + pointSemanticClassIds, + semanticClasses, + semanticPalette, showCurrentIncrement, showLocalSurface, showRollingMap, @@ -411,6 +458,27 @@ LaboratoryMetricEvidenceSceneHandle, useEffect(resetView, [corridor.forwardLengthM, mode]); 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 (
@@ -423,6 +491,15 @@ LaboratoryMetricEvidenceSceneHandle, Current increment Local SLAM surface Rolling-map occupied + {semanticLegendEntries.map((entry) => ( + + {entry.label} + + ))}
); diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx index cc30a5c..755aa28 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx @@ -5,18 +5,24 @@ import { RecordedEvidenceBoxOverlay, type RecordedEvidenceBox, } from "./RecordedEvidenceBoxOverlay"; +import { + RecordedEvidenceSemanticMaskOverlay, + type RecordedEvidenceSemanticOverlay, +} from "./RecordedEvidenceSemanticMaskOverlay"; export function RecordedEvidenceImageScene({ src, imageWidth, imageHeight, boxes, + semanticOverlay, ariaLabel, }: { src: string; imageWidth: number; imageHeight: number; boxes: readonly RecordedEvidenceBox[]; + semanticOverlay?: RecordedEvidenceSemanticOverlay; ariaLabel: string; }) { const [state, setState] = useState<"loading" | "ready" | "error">("loading"); @@ -33,12 +39,21 @@ export function RecordedEvidenceImageScene({ onError={() => setState("error")} /> {state === "ready" ? ( - + <> + {semanticOverlay ? ( + + ) : null} + + ) : (
{state === "loading" ? ( diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx new file mode 100644 index 0000000..8859264 --- /dev/null +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceSemanticMaskOverlay.tsx @@ -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; +} + +const MASK_CACHE_LIMIT = 48; +const decodedMaskCache = new Map(); +const pendingMaskCache = new Map(); + +const TOKEN_FALLBACKS: Record = { + "--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((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 { + 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; 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(null); + const [mask, setMask] = useState(null); + const [failure, setFailure] = useState(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(); + 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 ( + <> + + {failure ? ( +
+ {failure} +
+ ) : null} + + ); +} diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx index 8f66b1c..52ad380 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx @@ -8,6 +8,10 @@ import { type RecordedEvidenceBox, type RecordedEvidenceBoxTone, } from "./RecordedEvidenceBoxOverlay"; +import { + RecordedEvidenceSemanticMaskOverlay, + type RecordedEvidenceSemanticOverlay, +} from "./RecordedEvidenceSemanticMaskOverlay"; export type { RecordedEvidenceBox, RecordedEvidenceBoxTone }; @@ -17,6 +21,7 @@ export function RecordedEvidenceVideoScene({ imageWidth, imageHeight, boxes, + semanticOverlay, ariaLabel, interactive = true, onPlaybackChange, @@ -26,6 +31,7 @@ export function RecordedEvidenceVideoScene({ imageWidth: number; imageHeight: number; boxes: readonly RecordedEvidenceBox[]; + semanticOverlay?: RecordedEvidenceSemanticOverlay; ariaLabel: string; interactive?: boolean; onPlaybackChange?: (playback: RecordedObservationPlayback) => void; @@ -39,6 +45,13 @@ export function RecordedEvidenceVideoScene({ prepare onPlaybackChange={onPlaybackChange} /> + {semanticOverlay ? ( + + ) : null} > = { "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": "e47-semantic-slam", "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", @@ -180,6 +184,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { e46h: null, e46i: null, e46j: null, + e47: null, l34: null, l34a: null, l34b: null, @@ -303,6 +308,7 @@ export function advancedLaboratoryResultAvailable( : workId === "e46h-full-rectified-front-replay" ? results.e46h !== null : workId === "e46i-grounding-dino-full-replay" ? results.e46i !== 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 === "l34a-assisted-yolox-error-audit" ? results.l34a !== null : workId === "l34b-nested-box-consolidation-shadow" ? results.l34b !== null @@ -403,6 +409,8 @@ export async function fetchAdvancedLaboratoryResult( results.e46i = await fetchE46IGroundingDinoFullReplay({ fetcher, signal }); } else if (workId === "e46j-raw-fisheye-realtime") { 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") { results.l34 = await fetchL34RightYoloxTruthIsland({ fetcher, signal }); } else if (workId === "l34a-assisted-yolox-error-audit") { diff --git a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts index fba83e1..8543715 100644 --- a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -31,6 +31,7 @@ import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetector import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay"; import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay"; import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime"; +import type { E47SemanticSlamResult } from "./e47SemanticSlam"; import type { M4ThreatReplayResult } from "./m4ReplayThreat"; export interface AdvancedLaboratoryResults { @@ -59,6 +60,7 @@ export interface AdvancedLaboratoryResults { e46h: E46HFullRectifiedFrontReplayResult | null; e46i: E46IGroundingDinoFullReplayResult | null; e46j: E46JRawFisheyeRealtimeResult | null; + e47: E47SemanticSlamResult | null; l34: L34RightYoloxTruthIslandResult | null; l34a: L34AAssistedYoloxErrorAuditResult | null; l34b: L34BResult | null; diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index dabc09c..dabc72c 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -985,9 +985,9 @@ export async function fetchAdvancedLaboratoryResults({ e46b: null, e46c: null, e46d: null, - e46e: null, - e46f: null, + e46e: null, e46f: null, e46g: null, e46h: null, e46i: null, e46j: null, + e47: null, l34: null, l34a: null, l34b: null, diff --git a/apps/control-station/src/core/laboratory/e47SemanticSlam.ts b/apps/control-station/src/core/laboratory/e47SemanticSlam.ts new file mode 100644 index 0000000..bc6c35c --- /dev/null +++ b/apps/control-station/src/core/laboratory/e47SemanticSlam.ts @@ -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; + +export class E47SemanticSlamContractError extends Error {} + +const object = (value: unknown, label: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new E47SemanticSlamContractError(`${label}: ожидался объект.`); + } + return value as Record; +}; + +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 = ( + 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 { + 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 { + 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}`; +} diff --git a/apps/control-station/src/styles/l3-pointpillars-visual-audit.css b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css index 5bcd2b8..756504d 100644 --- a/apps/control-station/src/styles/l3-pointpillars-visual-audit.css +++ b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css @@ -88,6 +88,24 @@ 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 .m4-replay-threat-visual__overlay { bottom: 6.2rem; diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 10d3715..4e1f195 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -56,6 +56,7 @@ .m4-replay-threat-evidence-viewer .laboratory-evidence-viewer__controls { width: calc(100% - 1.2rem); + gap: 0.3rem; } .m4-replay-threat-evidence-viewer .l3-visual-audit__actions { @@ -67,7 +68,7 @@ } .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 { @@ -138,7 +139,7 @@ width: 0.38rem; height: 0.38rem; border-radius: 50%; - background: var(--nodedc-text-muted); + background: var(--laboratory-metric-legend-color, var(--nodedc-text-muted)); content: ""; } diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index c240c4e..422c490 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -39,6 +39,7 @@ import { E46GRectifiedDetectorBakeoffResultView } from "./E46GRectifiedDetectorB import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult"; import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult"; import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult"; +import { E47SemanticSlamResultView } from "./E47SemanticSlamResult"; import { M4ReplayThreatResultView } from "./M4ReplayThreatResult"; export { isAdvancedLaboratoryWorkId }; @@ -85,6 +86,9 @@ export function AdvancedLaboratoryResult({ if (workId === "m4-replay-threat" && results.m4Threat) { return ; } + if (workId === "e47-semantic-slam-shadow" && results.e47) { + return ; + } if (workId === "l3-pointpillars-visual-audit" && results.l3) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/E47SemanticSlamResult.tsx b/apps/control-station/src/workspaces/laboratory/E47SemanticSlamResult.tsx new file mode 100644 index 0000000..7638b63 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/E47SemanticSlamResult.tsx @@ -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 ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index fcc0fd3..c46ce0c 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -9,11 +9,20 @@ import { } from "../../components/laboratory/LaboratoryMetricEvidenceScene"; import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; import { RecordedEvidenceImageScene } from "../../components/laboratory/RecordedEvidenceImageScene"; +import type { + RecordedEvidenceSemanticClass, + RecordedEvidenceSemanticOverlay, + RecordedEvidenceSemanticPaletteEntry, +} from "../../components/laboratory/RecordedEvidenceSemanticMaskOverlay"; import { RecordedEvidenceVideoScene, type RecordedEvidenceBox, } from "../../components/laboratory/RecordedEvidenceVideoScene"; import { useRecordedEvidencePlayback } from "../../components/laboratory/useRecordedEvidencePlayback"; +import { + e47SemanticMaskUrl, + type E47SemanticClass, +} from "../../core/laboratory/e47SemanticSlam"; import type { M4ThreatCameraProposal, M4ThreatTimelineFrame, @@ -26,6 +35,7 @@ import { useM4ThreatTimelineFrame, useM4ThreatTimelineMetadata, } from "./useM4ThreatTimeline"; +import { useE47SemanticTimelineFrame } from "./useE47SemanticTimeline"; 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("video"); const [spatialMode, setSpatialMode] = useState("3d"); const [showCurrentIncrement, setShowCurrentIncrement] = useState(true); const [showLocalSurface, setShowLocalSurface] = useState(true); const [showRollingMap, setShowRollingMap] = useState(true); + const [showSemantic, setShowSemantic] = useState(true); const [expanded, setExpanded] = useState(false); const metricSceneRef = useRef(null); const metadata = useM4ThreatTimelineMetadata(resultId); @@ -140,6 +162,12 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { }, [resultId]); if (timelineFrame.activeFrame) lastFrameRef.current = timelineFrame.activeFrame; 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( frame && timelineFrame.activeSequence !== null @@ -173,6 +201,57 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { pointLimit: 20_000, }, ), [frame, metadata.timeline, timelineFrame.availableFrames]); + const semanticClasses = useMemo( + () => semantic?.taxonomy.map((item) => ({ + id: item.classId, + label: `semantic: ${item.label}`, + })) ?? [], + [semantic?.taxonomy], + ); + const semanticPalette = useMemo( + () => 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(() => { + 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 handleModeChange = (next: M4ThreatViewMode) => { @@ -205,40 +284,55 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
- {mode === "3d" || mode === "plan" ? ( + {mode === "3d" || mode === "plan" || semantic ? (
- - - + {mode === "3d" || mode === "plan" ? ( + <> + + + + + ) : null} + {semantic ? ( + + ) : null}
) : null} @@ -274,6 +368,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { {frame.spatialAvailable ? `${frame.pointCloudSampleCount}/${frame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames` : "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" : ""}
@@ -314,6 +411,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { imageWidth={timeline.imageWidth} imageHeight={timeline.imageHeight} boxes={activeBoxes} + semanticOverlay={mode === "video" ? semanticOverlay : undefined} ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} interactive={false} /> @@ -337,6 +435,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { imageWidth={timeline.imageWidth} imageHeight={timeline.imageHeight} boxes={activeBoxes} + semanticOverlay={mode === "camera" ? semanticOverlay : undefined} ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`} /> ) : null} @@ -360,6 +459,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { showCurrentIncrement={showCurrentIncrement} showLocalSurface={showLocalSurface} showRollingMap={showRollingMap} + pointSemanticClassIds={alignedSemanticPointIds} + semanticClasses={semanticClasses} + semanticPalette={semanticPalette} /> ) : null}
@@ -375,6 +477,24 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { {timelineFrame.error} ) : null} + {semantic && semanticTimeline.loading ? ( +
+
+ ) : null} + {semanticTimeline.error ? ( +
+ + {semanticTimeline.error} +
+ ) : null} + {semanticIntegrityError ? ( +
+ + {semanticIntegrityError} +
+ ) : null} {frame && !frame.spatialAvailable && (mode === "3d" || mode === "plan") ? (
На этом кадре нет квалифицированного body frame; сцена сохранена. @@ -409,7 +529,9 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) { return (
`${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": { profileId: "rig-camera-local-surface-v1", profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`, diff --git a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts index ab8fc46..b51c9b2 100644 --- a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts +++ b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts @@ -43,6 +43,7 @@ function mergeResults( e46h: next.e46h ?? current.e46h, e46i: next.e46i ?? current.e46i, e46j: next.e46j ?? current.e46j, + e47: next.e47 ?? current.e47, l34: next.l34 ?? current.l34, l34a: next.l34a ?? current.l34a, l34b: next.l34b ?? current.l34b, diff --git a/apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts b/apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts new file mode 100644 index 0000000..50336e0 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/useE47SemanticTimeline.ts @@ -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>( + () => new Map(), + ); + const [error, setError] = useState(null); + const chunksRef = useRef(chunks); + const inFlight = useRef(new Map()); + const activeStartRef = useRef(null); + chunksRef.current = chunks; + + useEffect(() => { + for (const controller of inFlight.current.values()) controller.abort(); + inFlight.current.clear(); + const empty = new Map(); + 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, + }; +} diff --git a/apps/control-station/test/e47SemanticSlam.test.mjs b/apps/control-station/test/e47SemanticSlam.test.mjs new file mode 100644 index 0000000..4f73a6f --- /dev/null +++ b/apps/control-station/test/e47SemanticSlam.test.mjs @@ -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/, + ); +}); diff --git a/apps/control-station/test/semanticEvidencePrimitives.test.mjs b/apps/control-station/test/semanticEvidencePrimitives.test.mjs new file mode 100644 index 0000000..89d44f7 --- /dev/null +++ b/apps/control-station/test/semanticEvidencePrimitives.test.mjs @@ -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(" { + 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\}/, + ); +});