feat(lab): visualize dual evidence replay
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
export type LaboratoryMetricPoint3 = readonly [number, number, number];
|
||||
export type LaboratoryMetricDecision = "threat" | "not-threat" | "unknown";
|
||||
export type LaboratoryMetricSceneMode = "3d" | "plan";
|
||||
|
||||
export interface LaboratoryMetricObstacleVisual {
|
||||
id: string;
|
||||
decision: LaboratoryMetricDecision;
|
||||
state: "current" | "held" | "expired";
|
||||
centroidBodyXyzM: LaboratoryMetricPoint3;
|
||||
cellCentersBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricRigVisual {
|
||||
lengthM: number;
|
||||
widthM: number;
|
||||
nominalSensorHeightM: number;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricCorridorVisual {
|
||||
forwardLengthM: number;
|
||||
rearMarginM: number;
|
||||
halfWidthM: number;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) return new THREE.Color(value);
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function disposeRenderable(object: THREE.Object3D): void {
|
||||
const renderable = object as THREE.Object3D & {
|
||||
geometry?: THREE.BufferGeometry;
|
||||
material?: THREE.Material | THREE.Material[];
|
||||
};
|
||||
renderable.geometry?.dispose();
|
||||
const materials = Array.isArray(renderable.material)
|
||||
? renderable.material
|
||||
: renderable.material
|
||||
? [renderable.material]
|
||||
: [];
|
||||
materials.forEach((material) => material.dispose());
|
||||
}
|
||||
|
||||
function scenePoint(point: LaboratoryMetricPoint3): LaboratoryMetricPoint3 {
|
||||
return [point[0], point[2], -point[1]];
|
||||
}
|
||||
|
||||
function positions(points: readonly LaboratoryMetricPoint3[]): Float32Array {
|
||||
const result = new Float32Array(points.length * 3);
|
||||
points.forEach((point, index) => {
|
||||
const [x, y, z] = scenePoint(point);
|
||||
const offset = index * 3;
|
||||
result[offset] = x;
|
||||
result[offset + 1] = y;
|
||||
result[offset + 2] = z;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function decisionColor(
|
||||
host: HTMLElement,
|
||||
decision: LaboratoryMetricDecision,
|
||||
): THREE.Color {
|
||||
if (decision === "threat") {
|
||||
return tokenColor(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
}
|
||||
if (decision === "not-threat") {
|
||||
return tokenColor(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
}
|
||||
return tokenColor(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
}
|
||||
|
||||
export function LaboratoryMetricEvidenceScene({
|
||||
pointCloudBodyXyzM,
|
||||
obstacles,
|
||||
rig,
|
||||
corridor,
|
||||
mode,
|
||||
label,
|
||||
}: {
|
||||
pointCloudBodyXyzM: readonly LaboratoryMetricPoint3[];
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
rig: LaboratoryMetricRigVisual;
|
||||
corridor: LaboratoryMetricCorridorVisual;
|
||||
mode: LaboratoryMetricSceneMode;
|
||||
label: string;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const contentRef = useRef<THREE.Group | null>(null);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать метрическую 3D-сцену.");
|
||||
return;
|
||||
}
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
|
||||
renderer.domElement.setAttribute("aria-label", label);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 300);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.4;
|
||||
controls.maxDistance = 80;
|
||||
const content = new THREE.Group();
|
||||
scene.add(content);
|
||||
sceneRef.current = scene;
|
||||
cameraRef.current = camera;
|
||||
controlsRef.current = controls;
|
||||
contentRef.current = content;
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
scene.traverse(disposeRenderable);
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
contentRef.current = null;
|
||||
};
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!host || !content) return;
|
||||
while (content.children.length) {
|
||||
const child = content.children[0];
|
||||
if (!child) break;
|
||||
content.remove(child);
|
||||
child.traverse(disposeRenderable);
|
||||
}
|
||||
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(pointCloudBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
contextGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
size: 1.7,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
|
||||
for (const obstacle of obstacles) {
|
||||
const color = decisionColor(host, obstacle.decision);
|
||||
const cellsGeometry = new THREE.BufferGeometry();
|
||||
cellsGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(positions(obstacle.cellCentersBodyXyzM), 3),
|
||||
);
|
||||
content.add(new THREE.Points(
|
||||
cellsGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color,
|
||||
size: obstacle.state === "current" ? 4.8 : 3.8,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: obstacle.state === "current" ? 0.94 : 0.45,
|
||||
depthWrite: false,
|
||||
}),
|
||||
));
|
||||
const centroid = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.1, 16, 12),
|
||||
new THREE.MeshBasicMaterial({ color, wireframe: obstacle.state !== "current" }),
|
||||
);
|
||||
centroid.position.fromArray(scenePoint(obstacle.centroidBodyXyzM));
|
||||
centroid.userData.evidenceId = obstacle.id;
|
||||
content.add(centroid);
|
||||
}
|
||||
|
||||
const corridorLength = rig.lengthM / 2 + corridor.forwardLengthM + corridor.rearMarginM;
|
||||
const corridorCenterX = (rig.lengthM / 2 + corridor.forwardLengthM - corridor.rearMarginM) / 2;
|
||||
const corridorMesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(corridorLength, corridor.halfWidthM * 2),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.11,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
corridorMesh.rotation.x = -Math.PI / 2;
|
||||
corridorMesh.position.set(corridorCenterX, 0.01, 0);
|
||||
content.add(corridorMesh);
|
||||
const corridorOutline = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(corridorLength, 0.01, corridor.halfWidthM * 2)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [232, 56, 126]),
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
}),
|
||||
);
|
||||
corridorOutline.position.set(corridorCenterX, 0.015, 0);
|
||||
content.add(corridorOutline);
|
||||
|
||||
const body = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(new THREE.BoxGeometry(rig.lengthM, 0.34, rig.widthM)),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
transparent: true,
|
||||
opacity: 0.86,
|
||||
}),
|
||||
);
|
||||
body.position.y = 0.17;
|
||||
content.add(body);
|
||||
const lidar = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.08, 0.08, 0.08, 20),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-foreground-rgb", [245, 245, 245]),
|
||||
}),
|
||||
);
|
||||
lidar.position.y = rig.nominalSensorHeightM;
|
||||
content.add(lidar);
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
Math.max(20, corridor.forwardLengthM * 2.5),
|
||||
40,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
|
||||
gridMaterials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.15;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
content.add(grid);
|
||||
}, [corridor, obstacles, pointCloudBodyXyzM, rig]);
|
||||
|
||||
const resetView = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0);
|
||||
if (mode === "plan") {
|
||||
camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001);
|
||||
camera.up.set(0, 0, -1);
|
||||
} else {
|
||||
camera.position.set(-4.5, 4.8, 8.5);
|
||||
camera.up.set(0, 1, 0);
|
||||
}
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
};
|
||||
|
||||
useEffect(resetView, [corridor.forwardLengthM, mode, obstacles]);
|
||||
|
||||
return (
|
||||
<div className="laboratory-metric-evidence-scene">
|
||||
<div ref={hostRef} className="laboratory-metric-evidence-scene__viewport">
|
||||
{renderError ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
onClick={resetView}
|
||||
>
|
||||
Сбросить ракурс
|
||||
</Button>
|
||||
<span>ЛКМ · вращение · колесо · масштаб · ПКМ · панорама</span>
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
<span data-decision="threat">Угроза</span>
|
||||
<span data-decision="not-threat">Вне коридора</span>
|
||||
<span data-decision="unknown">Неизвестно</span>
|
||||
<span data-decision="context">LiDAR context</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
export type RecordedEvidenceBoxTone =
|
||||
| "accent"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "neutral";
|
||||
|
||||
export interface RecordedEvidenceBox {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
label: string;
|
||||
tone: RecordedEvidenceBoxTone;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function rgba(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
|
||||
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
|
||||
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceVideoScene({
|
||||
source,
|
||||
playback,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
ariaLabel,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
playback: RecordedObservationPlayback;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
ariaLabel: string;
|
||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas) 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);
|
||||
|
||||
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;
|
||||
for (const item of boxes) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = toneColor(host, item.tone);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.dashed ? [5, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [boxes, imageHeight, imageWidth]);
|
||||
|
||||
return (
|
||||
<div className="recorded-evidence-video-scene" ref={hostRef}>
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
<canvas ref={canvasRef} role="img" aria-label={ariaLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,8 +35,10 @@ import { fetchE46GRectifiedDetectorBakeoff } from "./e46gRectifiedDetectorBakeof
|
||||
import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
|
||||
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
||||
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "m4-replay-threat"
|
||||
| "l3-pointpillars-visual-audit"
|
||||
| "l31-pointpillars-ravnoves"
|
||||
| "l32-pointpillars-camera-review"
|
||||
@@ -76,6 +78,7 @@ export interface AdvancedLaboratoryIndexItem {
|
||||
}
|
||||
|
||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"m4-replay-threat",
|
||||
"l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review",
|
||||
@@ -110,6 +113,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
];
|
||||
|
||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"m4-replay-threat": "m4-threat-replay",
|
||||
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
||||
"l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves",
|
||||
"l32-pointpillars-camera-review": "l32-pointpillars-camera-review",
|
||||
@@ -151,6 +155,7 @@ export function isAdvancedLaboratoryWorkId(
|
||||
|
||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m4Threat: null,
|
||||
l3: null,
|
||||
l31: null,
|
||||
l32: null,
|
||||
@@ -273,7 +278,8 @@ export function advancedLaboratoryResultAvailable(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
): boolean {
|
||||
return workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
return workId === "m4-replay-threat" ? results.m4Threat !== null
|
||||
: workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
: workId === "l31-pointpillars-ravnoves" ? results.l31 !== null
|
||||
: workId === "l32-pointpillars-camera-review" ? results.l32 !== null
|
||||
: workId === "l33-camera-first-detector-review" ? results.l33 !== null
|
||||
@@ -317,7 +323,9 @@ export async function fetchAdvancedLaboratoryResult(
|
||||
} = {},
|
||||
): Promise<AdvancedLaboratoryResults> {
|
||||
const results = emptyAdvancedLaboratoryResults();
|
||||
if (workId === "l3-pointpillars-visual-audit") {
|
||||
if (workId === "m4-replay-threat") {
|
||||
results.m4Threat = await fetchM4ThreatReplayResult({ fetcher, signal });
|
||||
} else if (workId === "l3-pointpillars-visual-audit") {
|
||||
results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal });
|
||||
} else if (workId === "l31-pointpillars-ravnoves") {
|
||||
results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal });
|
||||
|
||||
@@ -31,8 +31,10 @@ import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetector
|
||||
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
||||
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
||||
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
|
||||
export interface AdvancedLaboratoryResults {
|
||||
m4Threat: M4ThreatReplayResult | null;
|
||||
l3: L3PointPillarsVisualAuditResult | null;
|
||||
l31: L31PointPillarsRavnovesResult | null;
|
||||
l32: L32PointPillarsCameraReviewResult | null;
|
||||
|
||||
@@ -235,7 +235,6 @@ export class AdvancedLaboratoryContractError extends Error {
|
||||
this.name = "AdvancedLaboratoryContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
@@ -968,6 +967,7 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
const e39 = settledCatalogValue(settled[7]);
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
m4Threat: null,
|
||||
l3: null, l31: null,
|
||||
l32: null,
|
||||
l33: null,
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
export type M4ThreatDecision = "threat" | "not-threat" | "unknown";
|
||||
export type M4ThreatMotion = "moving" | "stationary" | "unknown";
|
||||
export type M4Point3 = readonly [number, number, number];
|
||||
|
||||
export interface M4ThreatReplayResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string;
|
||||
profileId: string;
|
||||
rigProfileId: string;
|
||||
corridorProfileId: string;
|
||||
sourceResultIds: {
|
||||
detector: string;
|
||||
geometry: string;
|
||||
temporal: string;
|
||||
};
|
||||
metrics: {
|
||||
decisions: Record<M4ThreatDecision, number>;
|
||||
evidence: {
|
||||
cameraOnly: number;
|
||||
currentMetric: number;
|
||||
staleOrHeld: number;
|
||||
};
|
||||
fixtures: {
|
||||
critical: number;
|
||||
criticalFalseNotThreat: number;
|
||||
passed: number;
|
||||
total: number;
|
||||
};
|
||||
runtime: {
|
||||
framesPerSecond: number;
|
||||
providerLatencyP50Ms: number;
|
||||
providerLatencyP95Ms: number;
|
||||
providerLatencyMaxMs: number;
|
||||
};
|
||||
reasonCounts: Readonly<Record<string, number>>;
|
||||
};
|
||||
configuration: {
|
||||
virtualBodyM: readonly [number, number];
|
||||
nominalSensorHeightM: number;
|
||||
forwardCorridorM: number;
|
||||
predictionHorizonSeconds: number;
|
||||
};
|
||||
limitations: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatAssessment {
|
||||
componentId: string;
|
||||
decision: M4ThreatDecision;
|
||||
corridorIntersection: "intersects" | "clear" | "unknown";
|
||||
relativeSpeedMps: number | null;
|
||||
closestApproachM: number | null;
|
||||
ttcSeconds: number | null;
|
||||
reasonCodes: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatMetricVisual {
|
||||
componentId: string;
|
||||
state: "current" | "held" | "expired";
|
||||
motion: M4ThreatMotion;
|
||||
centroidBodyXyzM: M4Point3;
|
||||
cellCentersBodyXyzM: readonly M4Point3[];
|
||||
assessment: M4ThreatAssessment;
|
||||
}
|
||||
|
||||
export interface M4ThreatCameraProposal {
|
||||
proposalId: string;
|
||||
bboxXyxy: readonly [number, number, number, number];
|
||||
objectness: number;
|
||||
semanticHint: string | null;
|
||||
occupiedSupport: boolean;
|
||||
rangeM: number | null;
|
||||
threatDecision: M4ThreatDecision | null;
|
||||
threatReasonCodes: readonly string[];
|
||||
}
|
||||
|
||||
export interface M4ThreatVisualFrame {
|
||||
resultId: string;
|
||||
ordinal: number;
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
pointCloudBodyXyzM: readonly M4Point3[];
|
||||
pointCloudSourceCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
metricObstacles: readonly M4ThreatMetricVisual[];
|
||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||
rig: {
|
||||
lengthM: number;
|
||||
widthM: number;
|
||||
nominalSensorHeightM: number;
|
||||
};
|
||||
corridor: {
|
||||
forwardLengthM: number;
|
||||
rearMarginM: number;
|
||||
halfWidthM: number;
|
||||
predictionHorizonSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface M4ThreatVisualIndexItem {
|
||||
ordinal: number;
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
sourceTimeNs: number;
|
||||
metricObstacleCount: number;
|
||||
cameraProposalCount: number;
|
||||
pointCloudSampleCount: number;
|
||||
}
|
||||
|
||||
export interface M4ThreatVideoFrame {
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
cameraProposals: readonly M4ThreatCameraProposal[];
|
||||
decisionCounts: Record<M4ThreatDecision, number>;
|
||||
}
|
||||
|
||||
export interface M4ThreatVideoOverlay {
|
||||
resultId: string;
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live";
|
||||
imageWidth: 800;
|
||||
imageHeight: 600;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
frames: readonly M4ThreatVideoFrame[];
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
class M4ThreatContractError extends Error {}
|
||||
const object = (value: unknown, label: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M4ThreatContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
const array = (value: unknown, label: string): readonly unknown[] => {
|
||||
if (!Array.isArray(value)) throw new M4ThreatContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
};
|
||||
const text = (value: unknown, label: string): string => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const number = (value: unknown, label: string): number => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const integer = (value: unknown, label: string): number => {
|
||||
const parsed = number(value, label);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new M4ThreatContractError(`${label}: ожидалось целое.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
const exact = <T extends string | number | boolean>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T => {
|
||||
if (value !== expected) throw new M4ThreatContractError(`${label}: нарушен контракт.`);
|
||||
return expected;
|
||||
};
|
||||
const optionalNumber = (value: unknown, label: string): number | null => (
|
||||
value === null ? null : number(value, label)
|
||||
);
|
||||
const vector = (value: unknown, size: number, label: string): number[] => {
|
||||
const parsed = array(value, label).map((item) => number(item, label));
|
||||
if (parsed.length !== size) throw new M4ThreatContractError(`${label}: неверная размерность.`);
|
||||
return parsed;
|
||||
};
|
||||
const decision = (value: unknown, label: string): M4ThreatDecision => {
|
||||
if (value !== "threat" && value !== "not-threat" && value !== "unknown") {
|
||||
throw new M4ThreatContractError(`${label}: неизвестное решение.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const motion = (value: unknown): M4ThreatMotion => {
|
||||
if (value !== "moving" && value !== "stationary" && value !== "unknown") {
|
||||
throw new M4ThreatContractError("M4.6 motion: неизвестное состояние.");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const resultId = (value: unknown): string => {
|
||||
const parsed = text(value, "M4.6 result id");
|
||||
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) {
|
||||
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
function parseAssessment(value: unknown): M4ThreatAssessment {
|
||||
const item = object(value, "M4.6 assessment");
|
||||
const intersection = text(item.corridor_intersection, "M4.6 intersection");
|
||||
if (intersection !== "intersects" && intersection !== "clear" && intersection !== "unknown") {
|
||||
throw new M4ThreatContractError("M4.6 intersection: неизвестное состояние.");
|
||||
}
|
||||
return {
|
||||
componentId: text(item.component_id, "M4.6 component"),
|
||||
decision: decision(item.decision, "M4.6 decision"),
|
||||
corridorIntersection: intersection,
|
||||
relativeSpeedMps: optionalNumber(item.relative_speed_mps, "M4.6 relative speed"),
|
||||
closestApproachM: optionalNumber(item.closest_approach_m, "M4.6 closest approach"),
|
||||
ttcSeconds: optionalNumber(item.ttc_seconds, "M4.6 TTC"),
|
||||
reasonCodes: array(item.reason_codes, "M4.6 reasons").map((reason) => text(reason, "M4.6 reason")),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCameraProposal(value: unknown): M4ThreatCameraProposal {
|
||||
const item = object(value, "M4.6 camera proposal");
|
||||
return {
|
||||
proposalId: text(item.proposal_id, "M4.6 proposal id"),
|
||||
bboxXyxy: vector(item.bbox_xyxy, 4, "M4.6 bbox") as [number, number, number, number],
|
||||
objectness: number(item.objectness, "M4.6 objectness"),
|
||||
semanticHint: item.semantic_hint === null ? null : text(item.semantic_hint, "M4.6 hint"),
|
||||
occupiedSupport: typeof item.occupied_support === "boolean" ? item.occupied_support : false,
|
||||
rangeM: optionalNumber(item.range_m, "M4.6 range"),
|
||||
threatDecision: item.threat_decision === null
|
||||
? null
|
||||
: decision(item.threat_decision, "M4.6 camera threat"),
|
||||
threatReasonCodes: array(item.threat_reason_codes, "M4.6 threat reasons").map(
|
||||
(reason) => text(reason, "M4.6 threat reason"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatReplayResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<M4ThreatReplayResult | null> {
|
||||
const response = await fetcher("/api/v1/laboratory/m4-threat/results?limit=1", {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 LAB недоступен: HTTP ${response.status}.`);
|
||||
const catalog = object(await response.json(), "M4.6 catalog");
|
||||
exact(catalog.schema_version, "missioncore.m4-threat-replay-catalog/v1", "M4.6 catalog schema");
|
||||
const items = array(catalog.items, "M4.6 results");
|
||||
if (!items.length) return null;
|
||||
const item = object(items[0], "M4.6 result");
|
||||
exact(item.schema_version, "missioncore.m4-threat-replay-view/v1", "M4.6 view schema");
|
||||
exact(item.accepted, true, "M4.6 acceptance");
|
||||
exact(item.authority, "replay-simulated", "M4.6 authority");
|
||||
exact(item.physical_collision_accepted, false, "M4.6 physical authority");
|
||||
exact(item.actuation_allowed, false, "M4.6 actuation");
|
||||
const metrics = object(item.metrics, "M4.6 metrics");
|
||||
const decisions = object(metrics.decisions, "M4.6 decisions");
|
||||
const evidence = object(metrics.evidence, "M4.6 evidence");
|
||||
const fixtures = object(metrics.fixtures, "M4.6 fixtures");
|
||||
const runtime = object(metrics.runtime, "M4.6 runtime");
|
||||
const configuration = object(item.configuration, "M4.6 configuration");
|
||||
const sourceResultIds = object(item.source_result_ids, "M4.6 sources");
|
||||
return {
|
||||
resultId: resultId(item.result_id),
|
||||
createdAtUtc: text(item.created_at_utc, "M4.6 created"),
|
||||
profileId: text(item.profile_id, "M4.6 profile"),
|
||||
rigProfileId: text(item.rig_profile_id, "M4.6 rig"),
|
||||
corridorProfileId: text(item.corridor_profile_id, "M4.6 corridor"),
|
||||
sourceResultIds: {
|
||||
detector: text(sourceResultIds.detector, "M4.6 detector"),
|
||||
geometry: text(sourceResultIds.geometry, "M4.6 geometry"),
|
||||
temporal: text(sourceResultIds.temporal, "M4.6 temporal"),
|
||||
},
|
||||
metrics: {
|
||||
decisions: {
|
||||
threat: integer(decisions.threat, "M4.6 threat count"),
|
||||
"not-threat": integer(decisions["not-threat"], "M4.6 clear count"),
|
||||
unknown: integer(decisions.unknown, "M4.6 unknown count"),
|
||||
},
|
||||
evidence: {
|
||||
cameraOnly: integer(evidence["camera-only"], "M4.6 camera-only"),
|
||||
currentMetric: integer(evidence["current-metric"], "M4.6 metric"),
|
||||
staleOrHeld: integer(evidence["stale-or-held"], "M4.6 stale"),
|
||||
},
|
||||
fixtures: {
|
||||
critical: integer(fixtures.critical, "M4.6 critical fixtures"),
|
||||
criticalFalseNotThreat: integer(fixtures.critical_false_not_threat, "M4.6 false-safe"),
|
||||
passed: integer(fixtures.passed, "M4.6 fixtures passed"),
|
||||
total: integer(fixtures.total, "M4.6 fixtures total"),
|
||||
},
|
||||
runtime: {
|
||||
framesPerSecond: number(runtime.frames_per_second, "M4.6 FPS"),
|
||||
providerLatencyP50Ms: number(runtime.provider_latency_p50_ms, "M4.6 p50"),
|
||||
providerLatencyP95Ms: number(runtime.provider_latency_p95_ms, "M4.6 p95"),
|
||||
providerLatencyMaxMs: number(runtime.provider_latency_max_ms, "M4.6 max"),
|
||||
},
|
||||
reasonCounts: Object.fromEntries(
|
||||
Object.entries(object(metrics.reason_counts, "M4.6 reasons")).map(
|
||||
([key, value]) => [key, integer(value, `M4.6 ${key}`)],
|
||||
),
|
||||
),
|
||||
},
|
||||
configuration: {
|
||||
virtualBodyM: vector(configuration.virtual_body_m, 2, "M4.6 body") as [number, number],
|
||||
nominalSensorHeightM: number(configuration.nominal_sensor_height_m, "M4.6 height"),
|
||||
forwardCorridorM: number(configuration.forward_corridor_m, "M4.6 corridor"),
|
||||
predictionHorizonSeconds: number(configuration.prediction_horizon_seconds, "M4.6 horizon"),
|
||||
},
|
||||
limitations: array(item.limitations, "M4.6 limitations").map((value) => text(value, "M4.6 limitation")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatVisualIndex(
|
||||
result: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<readonly M4ThreatVisualIndexItem[]> {
|
||||
const response = await fetcher(`/api/v1/laboratory/m4-threat/results/${result}/visuals`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual index: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 visual index");
|
||||
exact(payload.schema_version, "missioncore.m4-threat-visual-catalog/v1", "M4.6 visual schema");
|
||||
exact(payload.result_id, result, "M4.6 visual result");
|
||||
return array(payload.items, "M4.6 visual items").map((raw) => {
|
||||
const item = object(raw, "M4.6 visual item");
|
||||
return {
|
||||
ordinal: integer(item.ordinal, "M4.6 visual ordinal"),
|
||||
sequence: integer(item.sequence, "M4.6 visual sequence"),
|
||||
frameId: text(item.frame_id, "M4.6 visual frame"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 visual time"),
|
||||
metricObstacleCount: integer(item.metric_obstacle_count, "M4.6 visual metric"),
|
||||
cameraProposalCount: integer(item.camera_proposal_count, "M4.6 visual camera"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 visual points"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatVisual(
|
||||
result: string,
|
||||
ordinal: number,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatVisualFrame> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/visuals/${ordinal}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 visual frame: HTTP ${response.status}.`);
|
||||
const item = object(await response.json(), "M4.6 visual frame");
|
||||
exact(item.schema_version, "missioncore.perception-threat-visual-frame/v1", "M4.6 frame schema");
|
||||
exact(item.result_id, result, "M4.6 frame result");
|
||||
const rig = object(item.rig, "M4.6 visual rig");
|
||||
const corridor = object(item.corridor, "M4.6 visual corridor");
|
||||
return {
|
||||
resultId: result,
|
||||
ordinal: integer(item.ordinal, "M4.6 ordinal"),
|
||||
sequence: integer(item.sequence, "M4.6 sequence"),
|
||||
frameId: text(item.frame_id, "M4.6 frame id"),
|
||||
sourceTimeNs: integer(item.source_time_ns, "M4.6 frame time"),
|
||||
pointCloudBodyXyzM: array(item.point_cloud_body_xyz_m, "M4.6 points").map(
|
||||
(point) => vector(point, 3, "M4.6 point") as [number, number, number],
|
||||
),
|
||||
pointCloudSourceCount: integer(item.point_cloud_source_count, "M4.6 source points"),
|
||||
pointCloudSampleCount: integer(item.point_cloud_sample_count, "M4.6 sample points"),
|
||||
metricObstacles: array(item.metric_obstacles, "M4.6 metric visuals").map((raw) => {
|
||||
const value = object(raw, "M4.6 metric visual");
|
||||
const state = text(value.state, "M4.6 temporal state");
|
||||
if (state !== "current" && state !== "held" && state !== "expired") {
|
||||
throw new M4ThreatContractError("M4.6 temporal state: неизвестное состояние.");
|
||||
}
|
||||
return {
|
||||
componentId: text(value.component_id, "M4.6 visual component"),
|
||||
state,
|
||||
motion: motion(value.motion),
|
||||
centroidBodyXyzM: vector(value.centroid_body_xyz_m, 3, "M4.6 centroid") as [number, number, number],
|
||||
cellCentersBodyXyzM: array(value.cell_centers_body_xyz_m, "M4.6 cells").map(
|
||||
(point) => vector(point, 3, "M4.6 cell") as [number, number, number],
|
||||
),
|
||||
assessment: parseAssessment(value.assessment),
|
||||
};
|
||||
}),
|
||||
cameraProposals: array(item.camera_proposals, "M4.6 camera proposals").map(parseCameraProposal),
|
||||
rig: {
|
||||
lengthM: number(rig.length_m, "M4.6 rig length"),
|
||||
widthM: number(rig.width_m, "M4.6 rig width"),
|
||||
nominalSensorHeightM: number(rig.nominal_sensor_height_m, "M4.6 sensor height"),
|
||||
},
|
||||
corridor: {
|
||||
forwardLengthM: number(corridor.forward_length_m, "M4.6 forward corridor"),
|
||||
rearMarginM: number(corridor.rear_margin_m, "M4.6 rear corridor"),
|
||||
halfWidthM: number(corridor.half_width_m, "M4.6 half width"),
|
||||
predictionHorizonSeconds: number(corridor.prediction_horizon_seconds, "M4.6 visual horizon"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM4ThreatVideoOverlay(
|
||||
result: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M4ThreatVideoOverlay> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/m4-threat/results/${result}/video-overlay`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
if (!response.ok) throw new M4ThreatContractError(`M4.6 video overlay: HTTP ${response.status}.`);
|
||||
const payload = object(await response.json(), "M4.6 video overlay");
|
||||
exact(payload.schema_version, "missioncore.m4-threat-video-overlay/v1", "M4.6 video schema");
|
||||
exact(payload.result_id, result, "M4.6 video result");
|
||||
exact(payload.authority, "replay-simulated", "M4.6 video authority");
|
||||
const recorded = object(payload.recorded_source, "M4.6 recorded source");
|
||||
exact(
|
||||
recorded.session_id,
|
||||
"20260720T065719Z_viewer_live",
|
||||
"M4.6 recorded session",
|
||||
);
|
||||
const frames = array(payload.frames, "M4.6 video frames").map((raw, expectedIndex) => {
|
||||
const item = object(raw, "M4.6 video frame");
|
||||
const frameIndex = integer(item.frame_index, "M4.6 video index");
|
||||
if (frameIndex !== expectedIndex) throw new M4ThreatContractError("M4.6 video order.");
|
||||
const counts = object(item.decision_counts, "M4.6 video decisions");
|
||||
return {
|
||||
frameIndex,
|
||||
sessionSeconds: number(item.session_seconds, "M4.6 video time"),
|
||||
sourceAvailable: typeof item.source_available === "boolean" ? item.source_available : false,
|
||||
cameraProposals: array(item.camera_proposals, "M4.6 video proposals").map(parseCameraProposal),
|
||||
decisionCounts: {
|
||||
threat: integer(counts.threat, "M4.6 video threat"),
|
||||
"not-threat": integer(counts["not-threat"], "M4.6 video clear"),
|
||||
unknown: integer(counts.unknown, "M4.6 video unknown"),
|
||||
},
|
||||
};
|
||||
});
|
||||
exact(payload.frame_count, 4489, "M4.6 video frame count");
|
||||
return {
|
||||
resultId: result,
|
||||
recordedSourceSessionId: "20260720T065719Z_viewer_live",
|
||||
imageWidth: exact(payload.image_width, 800, "M4.6 image width"),
|
||||
imageHeight: exact(payload.image_height, 600, "M4.6 image height"),
|
||||
timelineStartSeconds: number(payload.timeline_start_seconds, "M4.6 video start"),
|
||||
timelineEndSeconds: number(payload.timeline_end_seconds, "M4.6 video end"),
|
||||
frames,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectM4ThreatVideoFrame(
|
||||
frames: readonly M4ThreatVideoFrame[],
|
||||
seconds: number,
|
||||
): M4ThreatVideoFrame | null {
|
||||
if (!frames.length) return null;
|
||||
let low = 0;
|
||||
let high = frames.length - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const current = frames[middle];
|
||||
if (!current || current.sessionSeconds < seconds) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const current = frames[low] ?? frames[frames.length - 1] ?? null;
|
||||
const previous = frames[Math.max(0, low - 1)] ?? null;
|
||||
if (!current || !previous) return current;
|
||||
return Math.abs(previous.sessionSeconds - seconds) <= Math.abs(current.sessionSeconds - seconds)
|
||||
? previous
|
||||
: current;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/laboratory-evidence-report.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/m4-replay-threat.css";
|
||||
@import "./styles/e35-degradation-recovery.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.e46c-video-scene {
|
||||
.recorded-evidence-video-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -55,12 +55,12 @@
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.e46c-video-scene > .recorded-media-player {
|
||||
.recorded-evidence-video-scene > .recorded-media-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.e46c-video-scene > canvas {
|
||||
.recorded-evidence-video-scene > canvas {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
.laboratory-metric-evidence-scene,
|
||||
.laboratory-metric-evidence-scene__viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__viewport p {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__toolbar {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__toolbar .nodedc-button,
|
||||
.laboratory-metric-evidence-scene__toolbar > span,
|
||||
.laboratory-metric-evidence-scene__legend {
|
||||
background: var(--nodedc-floating-surface);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__toolbar > span {
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
padding: 0.43rem 0.55rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
right: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
padding: 0.42rem 0.55rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span::before {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="threat"]::before {
|
||||
background: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="not-threat"]::before {
|
||||
background: rgb(var(--nodedc-success-rgb));
|
||||
}
|
||||
|
||||
.laboratory-metric-evidence-scene__legend span[data-decision="unknown"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.laboratory-metric-evidence-scene__toolbar > span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { E46GRectifiedDetectorBakeoffResultView } from "./E46GRectifiedDetectorB
|
||||
import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult";
|
||||
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
|
||||
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
|
||||
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
||||
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
@@ -81,6 +82,9 @@ export function AdvancedLaboratoryResult({
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "m4-replay-threat" && results.m4Threat) {
|
||||
return <M4ReplayThreatResultView rigLabel={rigLabel} result={results.m4Threat} />;
|
||||
}
|
||||
if (workId === "l3-pointpillars-visual-audit" && results.l3) {
|
||||
return <L3PointPillarsResult result={results.l3} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../../components/RecordedFmp4Player";
|
||||
import {
|
||||
RecordedEvidenceVideoScene,
|
||||
type RecordedEvidenceBox,
|
||||
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||
import {
|
||||
selectE46CVideoFrame,
|
||||
type E46CMotionState,
|
||||
@@ -11,26 +14,10 @@ import {
|
||||
} from "../../core/laboratory/e46cFullReplayWorldTracks";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
function color(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function stateColor(host: HTMLElement, state: E46CMotionState): string {
|
||||
if (state === "dynamic") return color(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
if (state === "static") return color(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
return color(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
function stateTone(state: E46CMotionState): RecordedEvidenceBox["tone"] {
|
||||
if (state === "dynamic") return "accent";
|
||||
if (state === "static") return "success";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function stateLabel(state: E46CMotionState): string {
|
||||
@@ -50,97 +37,40 @@ export function E46CRecordedVideoScene({
|
||||
playback: RecordedObservationPlayback;
|
||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const frame = useMemo(
|
||||
() => selectE46CVideoFrame(overlay.frames, playback.currentSeconds),
|
||||
[overlay.frames, playback.currentSeconds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas) 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 (!frame) return;
|
||||
|
||||
const scale = Math.min(
|
||||
width / overlay.imageWidth,
|
||||
height / overlay.imageHeight,
|
||||
);
|
||||
const drawWidth = overlay.imageWidth * scale;
|
||||
const drawHeight = overlay.imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of frame.objects) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = stateColor(host, item.motionState);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.cameraEvidenceCurrent ? [] : [5, 4]);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const identity = `S${item.routeTrackId}${
|
||||
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
|
||||
}`;
|
||||
const label = `${identity} · ${item.displayCategory} · ${stateLabel(
|
||||
const boxes = useMemo<readonly RecordedEvidenceBox[]>(() => (
|
||||
frame?.objects.map((item) => {
|
||||
const identity = `S${item.routeTrackId}${
|
||||
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
|
||||
}`;
|
||||
return {
|
||||
boxXyxy: item.boxXyxy,
|
||||
label: `${identity} · ${item.displayCategory} · ${stateLabel(
|
||||
item.motionState,
|
||||
)} · ${Math.round(item.score * 100)}%`;
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = context.measureText(label).width + 8;
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(
|
||||
offsetX + drawWidth - labelWidth,
|
||||
Math.max(offsetX, x),
|
||||
);
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(label, labelX + 4, labelY + fontSize + 1);
|
||||
}
|
||||
};
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [frame, overlay.imageHeight, overlay.imageWidth]);
|
||||
)} · ${Math.round(item.score * 100)}%`,
|
||||
tone: stateTone(item.motionState),
|
||||
dashed: !item.cameraEvidenceCurrent,
|
||||
};
|
||||
}) ?? []
|
||||
), [frame]);
|
||||
|
||||
return (
|
||||
<div className="e46c-video-scene" ref={hostRef}>
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={
|
||||
frame
|
||||
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
|
||||
: "E46C recorded video overlay"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<RecordedEvidenceVideoScene
|
||||
source={source}
|
||||
playback={playback}
|
||||
imageWidth={overlay.imageWidth}
|
||||
imageHeight={overlay.imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={
|
||||
frame
|
||||
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
|
||||
: "E46C recorded video overlay"
|
||||
}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { M4ThreatReplayResult } from "../../core/laboratory/m4ReplayThreat";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
|
||||
|
||||
export function M4ReplayThreatResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M4ThreatReplayResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const totalAssessments = Object.values(metrics.decisions).reduce(
|
||||
(sum, value) => sum + value,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.6 · dual-evidence threat replay"
|
||||
description="Camera и LiDAR дают независимые доказательства, после чего один source-neutral слой оценивает пересечение виртуального коридора, ближайшее сближение и TTC. Ни один сенсор не назначен first."
|
||||
status="4489/4489 · replay-simulated · accepted"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{
|
||||
label: "Конфигурация",
|
||||
value: `${rigLabel} · RIGHT camera + LiDAR geometry · recorded replay`,
|
||||
},
|
||||
{
|
||||
label: "Виртуальный корпус",
|
||||
value: `${result.configuration.virtualBodyM[0]}×${result.configuration.virtualBodyM[1]} м · LiDAR ${result.configuration.nominalSensorHeightM} м`,
|
||||
},
|
||||
{
|
||||
label: "Коридор",
|
||||
value: `${result.configuration.forwardCorridorM} м · horizon ${result.configuration.predictionHorizonSeconds} с`,
|
||||
},
|
||||
{
|
||||
label: "Визуал",
|
||||
value: "4489-frame VIDEO · 32 exact CAMERA/3D/PLAN samples",
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?",
|
||||
approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Geometry-only объекты получают метрическую оценку; camera-only и stale/held остаются unknown. Отдельная матрица из 9 детерминированных сценариев проверяет статические, сближающиеся и расходящиеся случаи.",
|
||||
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only наблюдений учтены; ${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only оценок не потеряны. Критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
|
||||
limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. Постоянная скорость — ограниченная модель, а independent object truth остаётся следующим gate.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "dual-evidence-replay-threat/v1",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceResultIds.detector,
|
||||
version: "frozen camera proposals",
|
||||
role: "независимое image-space evidence без safety authority",
|
||||
identitySha256: result.sourceResultIds.detector.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceResultIds.geometry,
|
||||
version: "frozen metric geometry",
|
||||
role: "LiDAR occupied components и camera association",
|
||||
identitySha256: result.sourceResultIds.geometry.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "source",
|
||||
name: result.sourceResultIds.temporal,
|
||||
version: "frozen temporal object map",
|
||||
role: "current / held / expired и bounded motion history",
|
||||
identitySha256: result.sourceResultIds.temporal.split("-").at(-1) ?? null,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "dual-evidence virtual corridor",
|
||||
version: result.profileId,
|
||||
role: "classless corridor intersection, closest approach and TTC",
|
||||
identitySha256: result.resultId.split("-").at(-1) ?? null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="M4.6 VISUAL EVIDENCE · VIDEO / CAMERA / 3D / PLAN"
|
||||
title="Синхронный контроль рамок, расстояний, облака точек и виртуального коридора"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<M4ReplayThreatVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Dual-evidence слой готов к следующей CV-итерации на recorded replay"
|
||||
status="Replay gate accepted · physical authority withheld"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
label: "Replay frames",
|
||||
value: "4489/4489",
|
||||
hint: `${formatNumber(metrics.runtime.framesPerSecond, 1)} FPS offline`,
|
||||
},
|
||||
{
|
||||
label: "Metric evidence",
|
||||
value: metrics.evidence.currentMetric.toLocaleString("ru-RU"),
|
||||
hint: `${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only`,
|
||||
},
|
||||
{
|
||||
label: "Threat / clear",
|
||||
value: `${metrics.decisions.threat.toLocaleString("ru-RU")} / ${metrics.decisions["not-threat"].toLocaleString("ru-RU")}`,
|
||||
hint: `${totalAssessments.toLocaleString("ru-RU")} assessments accounted`,
|
||||
},
|
||||
{
|
||||
label: "Critical false-safe",
|
||||
value: String(metrics.fixtures.criticalFalseNotThreat),
|
||||
hint: `${metrics.fixtures.passed}/${metrics.fixtures.total} deterministic fixtures passed`,
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На неизменяемом RAVNOVES00 каждый metric, stale/held и camera-only объект получил ровно одну консервативную оценку. Geometry-only препятствия участвуют в threat-решении без класса, camera-only и просроченные данные не превращаются в safe. Видео, точные camera samples и метрическое 3D-доказательство доступны в одном viewer.",
|
||||
notProved: "Не доказаны live realtime, измеренная геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.",
|
||||
decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, Select } from "@nodedc/ui-react";
|
||||
|
||||
import type { RecordedObservationPlayback } from "../../components/RecordedFmp4Player";
|
||||
import {
|
||||
LaboratoryMetricEvidenceScene,
|
||||
type LaboratoryMetricSceneMode,
|
||||
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
RecordedEvidenceVideoScene,
|
||||
type RecordedEvidenceBox,
|
||||
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||
import {
|
||||
fetchM4ThreatVideoOverlay,
|
||||
fetchM4ThreatVisual,
|
||||
fetchM4ThreatVisualIndex,
|
||||
selectM4ThreatVideoFrame,
|
||||
type M4ThreatCameraProposal,
|
||||
type M4ThreatVideoOverlay,
|
||||
type M4ThreatVisualFrame,
|
||||
type M4ThreatVisualIndexItem,
|
||||
} from "../../core/laboratory/m4ReplayThreat";
|
||||
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
|
||||
import { replayObservationSession } from "../../core/observation/sessionArchive";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
|
||||
|
||||
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
|
||||
if (proposal.threatDecision === "threat") return "danger";
|
||||
if (proposal.threatDecision === "not-threat") return "success";
|
||||
if (proposal.threatDecision === "unknown") return "warning";
|
||||
return proposal.occupiedSupport ? "accent" : "warning";
|
||||
}
|
||||
|
||||
function proposalLabel(proposal: M4ThreatCameraProposal): string {
|
||||
const decision = proposal.threatDecision ?? "unknown";
|
||||
if (proposal.rangeM === null) return decision;
|
||||
const range = `${proposal.rangeM.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`;
|
||||
return `${range} · ${decision}`;
|
||||
}
|
||||
|
||||
function boxes(proposals: readonly M4ThreatCameraProposal[]): readonly RecordedEvidenceBox[] {
|
||||
return proposals.map((proposal) => ({
|
||||
boxXyxy: proposal.bboxXyxy,
|
||||
label: proposalLabel(proposal),
|
||||
tone: toneForProposal(proposal),
|
||||
dashed: !proposal.occupiedSupport,
|
||||
}));
|
||||
}
|
||||
|
||||
function message(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
const [mode, setMode] = useState<M4ThreatViewMode>("video");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [index, setIndex] = useState<readonly M4ThreatVisualIndexItem[]>([]);
|
||||
const [ordinal, setOrdinal] = useState(1);
|
||||
const [frame, setFrame] = useState<M4ThreatVisualFrame | null>(null);
|
||||
const [sampleLoading, setSampleLoading] = useState(true);
|
||||
const [sampleError, setSampleError] = useState<string | null>(null);
|
||||
const [videoOverlay, setVideoOverlay] = useState<M4ThreatVideoOverlay | null>(null);
|
||||
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
|
||||
const [videoLoading, setVideoLoading] = useState(false);
|
||||
const [videoError, setVideoError] = useState<string | null>(null);
|
||||
const [videoPlayback, setVideoPlayback] = useState<RecordedObservationPlayback>({
|
||||
currentSeconds: 0,
|
||||
playing: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setSampleLoading(true);
|
||||
setSampleError(null);
|
||||
void fetchM4ThreatVisualIndex(resultId, { signal: controller.signal })
|
||||
.then((items) => {
|
||||
if (!controller.signal.aborted) setIndex(items);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setSampleError(message(caught, "Индекс визуальных кадров M4.6 недоступен."));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setSampleLoading(true);
|
||||
setSampleError(null);
|
||||
setFrame(null);
|
||||
void fetchM4ThreatVisual(resultId, ordinal, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setFrame(next);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setSampleError(message(caught, "Метрический visual M4.6 недоступен."));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setSampleLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [ordinal, resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((mode !== "video" && mode !== "camera") || (videoOverlay && videoSource)) return;
|
||||
const controller = new AbortController();
|
||||
setVideoLoading(true);
|
||||
setVideoError(null);
|
||||
void (async () => {
|
||||
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const replay = await replayObservationSession(overlay.recordedSourceSessionId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (replay.kind !== "ready") {
|
||||
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
|
||||
}
|
||||
const source = recordedObservationSources(replay.launch).find(
|
||||
(candidate) =>
|
||||
candidate.modality === "video" &&
|
||||
candidate.semanticChannelId === "camera.video.recorded",
|
||||
);
|
||||
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
|
||||
? source.delivery
|
||||
: null;
|
||||
if (
|
||||
!source ||
|
||||
!delivery ||
|
||||
delivery.timelineStartSeconds !== overlay.timelineStartSeconds ||
|
||||
delivery.timelineEndSeconds < overlay.timelineEndSeconds
|
||||
) {
|
||||
throw new Error("RIGHT-видео не совпало с временным контрактом M4.6.");
|
||||
}
|
||||
if (controller.signal.aborted) return;
|
||||
setVideoOverlay(overlay);
|
||||
setVideoSource(source);
|
||||
setVideoPlayback({
|
||||
currentSeconds: overlay.timelineStartSeconds,
|
||||
playing: false,
|
||||
});
|
||||
})()
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setVideoError(message(caught, "Видео-доказательство M4.6 недоступно."));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setVideoLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [mode, resultId, videoOverlay, videoSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "camera" || !frame) return;
|
||||
setVideoPlayback({
|
||||
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
|
||||
playing: false,
|
||||
});
|
||||
}, [frame, mode]);
|
||||
|
||||
const activeVideoFrame = useMemo(
|
||||
() => videoOverlay
|
||||
? selectM4ThreatVideoFrame(videoOverlay.frames, videoPlayback.currentSeconds)
|
||||
: null,
|
||||
[videoOverlay, videoPlayback.currentSeconds],
|
||||
);
|
||||
const activeProposals = mode === "camera"
|
||||
? frame?.cameraProposals ?? []
|
||||
: activeVideoFrame?.cameraProposals ?? [];
|
||||
const activeBoxes = useMemo(() => boxes(activeProposals), [activeProposals]);
|
||||
const selectedItem = index.find((item) => item.ordinal === ordinal) ?? null;
|
||||
const threatObstacles = frame?.metricObstacles.filter(
|
||||
(item) => item.assessment.decision === "threat",
|
||||
) ?? [];
|
||||
const nearest = frame?.metricObstacles
|
||||
.map((item) => item.assessment.closestApproachM)
|
||||
.filter((value): value is number => value !== null)
|
||||
.sort((left, right) => left - right)[0] ?? null;
|
||||
|
||||
const seekVideo = (seconds: number) => {
|
||||
if (!videoOverlay) return;
|
||||
setVideoPlayback({
|
||||
currentSeconds: Math.min(
|
||||
videoOverlay.timelineEndSeconds,
|
||||
Math.max(videoOverlay.timelineStartSeconds, seconds),
|
||||
),
|
||||
playing: false,
|
||||
});
|
||||
};
|
||||
const navigate = (offset: -1 | 1) => {
|
||||
const count = Math.max(index.length, 32);
|
||||
setOrdinal((current) => ((current - 1 + offset + count) % count) + 1);
|
||||
};
|
||||
|
||||
const actions = mode === "video" ? (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Назад на 5 секунд" onClick={() => seekVideo(videoPlayback.currentSeconds - 5)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Вперёд на 5 секунд" onClick={() => seekVideo(videoPlayback.currentSeconds + 5)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Перейти к метрическому sample M4.6"
|
||||
value={String(ordinal)}
|
||||
options={(index.length ? index : Array.from({ length: 32 }, (_, position) => ({
|
||||
ordinal: position + 1,
|
||||
sequence: position,
|
||||
frameId: "",
|
||||
sourceTimeNs: 0,
|
||||
metricObstacleCount: 0,
|
||||
cameraProposalCount: 0,
|
||||
pointCloudSampleCount: 0,
|
||||
}))).map((item) => ({
|
||||
value: String(item.ordinal),
|
||||
label: `${item.ordinal}/32 · frame ${item.sequence} · ${item.metricObstacleCount} metric objects`,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти sample"
|
||||
onChange={(value) => {
|
||||
const nextOrdinal = Number(value);
|
||||
const target = index.find((item) => item.ordinal === nextOrdinal);
|
||||
setOrdinal(nextOrdinal);
|
||||
if (target) seekVideo(target.sourceTimeNs / 1_000_000_000);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="l3-visual-audit__actions">
|
||||
<div className="l3-visual-audit__pagination">
|
||||
<IconButton label="Предыдущий sample M4.6" onClick={() => navigate(-1)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Следующий sample M4.6" onClick={() => navigate(1)}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Select
|
||||
label="Выбрать sample M4.6"
|
||||
value={String(ordinal)}
|
||||
options={index.map((item) => ({
|
||||
value: String(item.ordinal),
|
||||
label: `${item.ordinal}/32 · frame ${item.sequence} · ${item.metricObstacleCount} metric · ${item.cameraProposalCount} camera`,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
searchable
|
||||
searchPlaceholder="Найти sample"
|
||||
onChange={(value) => setOrdinal(Number(value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const overlay = mode === "video" && videoOverlay ? (
|
||||
<div className="l3-visual-audit__overlay l3-visual-audit__overlay--video">
|
||||
<div>
|
||||
<span>RAVNOVES00 · recorded RIGHT</span>
|
||||
<strong>
|
||||
+{(videoPlayback.currentSeconds - videoOverlay.timelineStartSeconds).toFixed(1)} с
|
||||
{activeVideoFrame ? ` · frame ${activeVideoFrame.frameIndex}` : ""}
|
||||
</strong>
|
||||
<small>{videoPlayback.playing ? "воспроизведение" : "пауза / seek"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Camera evidence</span>
|
||||
<strong>{activeVideoFrame?.cameraProposals.length ?? 0} рамок · distance при LiDAR support</strong>
|
||||
<small>пунктир = camera-only · всегда unknown</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Replay decision</span>
|
||||
<strong>
|
||||
{activeVideoFrame?.decisionCounts.threat ?? 0} threat · {activeVideoFrame?.decisionCounts["not-threat"] ?? 0} clear · {activeVideoFrame?.decisionCounts.unknown ?? 0} unknown
|
||||
</strong>
|
||||
<small>REPLAY-SIMULATED · не live и не safety authority</small>
|
||||
</div>
|
||||
</div>
|
||||
) : frame ? (
|
||||
<div className="l3-visual-audit__overlay">
|
||||
<div>
|
||||
<span>RAVNOVES00 · exact replay sample</span>
|
||||
<strong>frame {frame.sequence} · sample {frame.ordinal}/32</strong>
|
||||
<small>{(frame.sourceTimeNs / 1_000_000_000).toFixed(3)} с · {selectedItem?.frameId}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Dual evidence</span>
|
||||
<strong>{frame.metricObstacles.length} metric · {frame.cameraProposals.length} camera</strong>
|
||||
<small>{frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} LiDAR points shown</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Virtual corridor</span>
|
||||
<strong>{threatObstacles.length} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}</strong>
|
||||
<small>{frame.corridor.forwardLengthM} м · body {frame.rig.lengthM}×{frame.rig.widthM} м · REPLAY-SIMULATED</small>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
let content;
|
||||
if (mode === "video" || mode === "camera") {
|
||||
content = videoLoading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Связываем 4489 решений M4.6 с RIGHT-видео</span>
|
||||
</div>
|
||||
) : videoError || !videoOverlay || !videoSource ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{videoError ?? "Видео-доказательство M4.6 недоступно."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<RecordedEvidenceVideoScene
|
||||
source={videoSource}
|
||||
playback={videoPlayback}
|
||||
imageWidth={videoOverlay.imageWidth}
|
||||
imageHeight={videoOverlay.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
ariaLabel={
|
||||
mode === "camera"
|
||||
? `M4.6 exact camera sample ${ordinal}: ${activeBoxes.length} proposals`
|
||||
: `M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`
|
||||
}
|
||||
onPlaybackChange={setVideoPlayback}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = sampleLoading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем синхронное облако точек M4.6</span>
|
||||
</div>
|
||||
) : sampleError || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{sampleError ?? "Метрический visual M4.6 недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
|
||||
obstacles={frame.metricObstacles.map((obstacle) => ({
|
||||
id: obstacle.componentId,
|
||||
decision: obstacle.assessment.decision,
|
||||
state: obstacle.state,
|
||||
centroidBodyXyzM: obstacle.centroidBodyXyzM,
|
||||
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
|
||||
}))}
|
||||
rig={frame.rig}
|
||||
corridor={frame.corridor}
|
||||
mode={mode}
|
||||
label={`M4.6 metric point cloud, frame ${frame.sequence}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.6 dual-evidence replay: video, camera and metric 3D"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
{ value: "camera", label: "CAMERA" },
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "PLAN" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
>
|
||||
{content}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
|
||||
export type LaboratoryProfileId =
|
||||
| "rig-dual-evidence-virtual-corridor-v1"
|
||||
| "rig-camera-local-surface-v1"
|
||||
| "rig-track-geometry-temporal-v1"
|
||||
| "rig-ravnoves-perception-gate-v1"
|
||||
@@ -56,6 +57,13 @@ interface KnownWorkDefinition {
|
||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||
|
||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
||||
"m4-replay-threat": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
|
||||
experimentId: "m4-ravnoves00-dual-evidence-threat",
|
||||
experimentName: "RAVNOVES00 dual-evidence threat qualification",
|
||||
variantName: "M4.6 · virtual corridor replay · VIDEO/CAMERA/3D",
|
||||
},
|
||||
"e28-local-surface": {
|
||||
profileId: "rig-camera-local-surface-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
||||
|
||||
@@ -18,6 +18,7 @@ function mergeResults(
|
||||
next: AdvancedLaboratoryResults,
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||
l3: next.l3 ?? current.l3,
|
||||
l31: next.l31 ?? current.l31,
|
||||
l32: next.l32 ?? current.l32,
|
||||
|
||||
@@ -91,8 +91,8 @@ test("E46C decodes the complete path-free temporal video overlay", async () => {
|
||||
assert.equal(selectE46CVideoFrame(overlay.frames, overlay.frames[20].sessionSeconds).frameIndex, 20);
|
||||
});
|
||||
|
||||
test("E46C viewer opens with full VIDEO and reuses the admitted recorded player", async () => {
|
||||
const [visual, videoScene, player] = await Promise.all([
|
||||
test("E46C viewer opens with full VIDEO and reuses the shared recorded overlay scene", async () => {
|
||||
const [visual, videoScene, sharedScene, player] = await Promise.all([
|
||||
readFile(
|
||||
new URL(
|
||||
"../src/workspaces/laboratory/E46CFullReplayWorldTracksVisual.tsx",
|
||||
@@ -107,14 +107,22 @@ test("E46C viewer opens with full VIDEO and reuses the admitted recorded player"
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL(
|
||||
"../src/components/laboratory/RecordedEvidenceVideoScene.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(visual, /useState<E46CViewMode>\("video"\)/);
|
||||
assert.match(visual, /\{ value: "video", label: "VIDEO" \}/);
|
||||
assert.match(visual, /replayObservationSession\(overlay\.recordedSourceSessionId/);
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(videoScene, /<RecordedEvidenceVideoScene/);
|
||||
assert.match(videoScene, /selectE46CVideoFrame/);
|
||||
assert.match(sharedScene, /<RecordedFmp4Player/);
|
||||
assert.match(player, /requestVideoFrameCallback/);
|
||||
assert.match(player, /controls=\{interactive\}/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchM4ThreatReplayResult;
|
||||
let fetchM4ThreatVisual;
|
||||
let fetchM4ThreatVideoOverlay;
|
||||
let selectM4ThreatVideoFrame;
|
||||
|
||||
const resultId = `m4-threat-replay-${"a".repeat(64)}`;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchM4ThreatReplayResult,
|
||||
fetchM4ThreatVisual,
|
||||
fetchM4ThreatVideoOverlay,
|
||||
selectM4ThreatVideoFrame,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/m4ReplayThreat.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function proposal(overrides = {}) {
|
||||
return {
|
||||
proposal_id: "proposal-1",
|
||||
bbox_xyxy: [100, 120, 240, 360],
|
||||
objectness: 0.91,
|
||||
semantic_hint: "person",
|
||||
occupied_support: false,
|
||||
range_m: null,
|
||||
threat_decision: "unknown",
|
||||
threat_reason_codes: ["camera-only-no-metric-geometry"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("M4.6 decodes accepted dual-evidence result without physical authority", async () => {
|
||||
const result = await fetchM4ThreatReplayResult({
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.m4-threat-replay-catalog/v1",
|
||||
items: [{
|
||||
schema_version: "missioncore.m4-threat-replay-view/v1",
|
||||
result_id: resultId,
|
||||
created_at_utc: "2026-08-05T15:36:01.553Z",
|
||||
status: "accepted",
|
||||
profile_id: "m4-ravnoves00-virtual-corridor/v1",
|
||||
rig_profile_id: "virtual-handheld-body-1000x600/v1",
|
||||
corridor_profile_id: "ravnoves00-forward-corridor-8m/v1",
|
||||
source_result_ids: {
|
||||
detector: `m4-detector-replay-${"b".repeat(64)}`,
|
||||
geometry: `m4-geometry-replay-${"c".repeat(64)}`,
|
||||
temporal: `m4-temporal-replay-${"d".repeat(64)}`,
|
||||
},
|
||||
metrics: {
|
||||
decisions: { threat: 8010, "not-threat": 6610, unknown: 60832 },
|
||||
evidence: { "camera-only": 10158, "current-metric": 27299, "stale-or-held": 37995 },
|
||||
fixtures: { critical: 4, critical_false_not_threat: 0, passed: 9, total: 9 },
|
||||
runtime: {
|
||||
frames_per_second: 116.4,
|
||||
provider_latency_p50_ms: 4.3,
|
||||
provider_latency_p95_ms: 19.8,
|
||||
provider_latency_max_ms: 194.3,
|
||||
},
|
||||
reason_counts: { "geometry-only-evidence": 21958 },
|
||||
},
|
||||
configuration: {
|
||||
virtual_body_m: [1, 0.6],
|
||||
nominal_sensor_height_m: 1.25,
|
||||
forward_corridor_m: 8,
|
||||
prediction_horizon_seconds: 5,
|
||||
},
|
||||
limitations: ["replay only"],
|
||||
accepted: true,
|
||||
authority: "replay-simulated",
|
||||
physical_collision_accepted: false,
|
||||
actuation_allowed: false,
|
||||
}],
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(result.resultId, resultId);
|
||||
assert.equal(result.metrics.evidence.currentMetric, 27299);
|
||||
assert.equal(result.metrics.fixtures.criticalFalseNotThreat, 0);
|
||||
assert.deepEqual(result.configuration.virtualBodyM, [1, 0.6]);
|
||||
});
|
||||
|
||||
test("M4.6 binds exact CAMERA and metric 3D evidence to one replay frame", async () => {
|
||||
const frame = await fetchM4ThreatVisual(resultId, 1, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.perception-threat-visual-frame/v1",
|
||||
result_id: resultId,
|
||||
ordinal: 1,
|
||||
sequence: 20,
|
||||
frame_id: "frame-000020",
|
||||
source_time_ns: 37421857292,
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.1], [2, 0.2, 0.3]],
|
||||
point_cloud_source_count: 12000,
|
||||
point_cloud_sample_count: 2,
|
||||
metric_obstacles: [{
|
||||
component_id: "temporal-20-1",
|
||||
state: "current",
|
||||
motion: "moving",
|
||||
centroid_body_xyz_m: [2, 0.1, 0.4],
|
||||
cell_centers_body_xyz_m: [[2, 0.1, 0.4]],
|
||||
assessment: {
|
||||
component_id: "temporal-20-1",
|
||||
decision: "threat",
|
||||
corridor_intersection: "intersects",
|
||||
relative_speed_mps: 1.2,
|
||||
closest_approach_m: 0.4,
|
||||
ttc_seconds: 1.6,
|
||||
reason_codes: ["geometry-only-evidence"],
|
||||
},
|
||||
}],
|
||||
camera_proposals: [proposal()],
|
||||
rig: { length_m: 1, width_m: 0.6, nominal_sensor_height_m: 1.25 },
|
||||
corridor: {
|
||||
forward_length_m: 8,
|
||||
rear_margin_m: 0.5,
|
||||
half_width_m: 0.5,
|
||||
prediction_horizon_seconds: 5,
|
||||
},
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(frame.sequence, 20);
|
||||
assert.equal(frame.metricObstacles[0].assessment.decision, "threat");
|
||||
assert.equal(frame.cameraProposals[0].threatDecision, "unknown");
|
||||
assert.equal(frame.pointCloudSampleCount, 2);
|
||||
});
|
||||
|
||||
test("M4.6 full video preserves camera-only unknown and nearest-frame selection", async () => {
|
||||
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.m4-threat-video-overlay/v1",
|
||||
result_id: resultId,
|
||||
recorded_source: { session_id: "20260720T065719Z_viewer_live" },
|
||||
image_width: 800,
|
||||
image_height: 600,
|
||||
timeline_start_seconds: 35.421857292,
|
||||
timeline_end_seconds: 484.044857292,
|
||||
frame_count: 4489,
|
||||
frames: [
|
||||
{
|
||||
frame_index: 0,
|
||||
session_seconds: 35.421857292,
|
||||
source_available: true,
|
||||
camera_proposals: [],
|
||||
decision_counts: { threat: 0, "not-threat": 0, unknown: 0 },
|
||||
},
|
||||
{
|
||||
frame_index: 1,
|
||||
session_seconds: 35.521857292,
|
||||
source_available: true,
|
||||
camera_proposals: [proposal()],
|
||||
decision_counts: { threat: 0, "not-threat": 0, unknown: 1 },
|
||||
},
|
||||
],
|
||||
authority: "replay-simulated",
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(overlay.frames[1].cameraProposals[0].rangeM, null);
|
||||
assert.equal(selectM4ThreatVideoFrame(overlay.frames, 35.50).frameIndex, 1);
|
||||
});
|
||||
|
||||
test("M4.6 viewer reuses shared video and metric evidence renderers", async () => {
|
||||
const [visual, videoScene, metricScene] = await Promise.all([
|
||||
readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
assert.match(visual, /<RecordedEvidenceVideoScene/);
|
||||
assert.match(visual, /<LaboratoryMetricEvidenceScene/);
|
||||
assert.match(visual, /label: "VIDEO"/);
|
||||
assert.match(visual, /label: "CAMERA"/);
|
||||
assert.match(visual, /label: "3D"/);
|
||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
||||
assert.match(metricScene, /OrbitControls/);
|
||||
});
|
||||
Reference in New Issue
Block a user