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 { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay";
|
||||||
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay";
|
||||||
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||||
|
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||||
|
|
||||||
export type AdvancedLaboratoryWorkId =
|
export type AdvancedLaboratoryWorkId =
|
||||||
|
| "m4-replay-threat"
|
||||||
| "l3-pointpillars-visual-audit"
|
| "l3-pointpillars-visual-audit"
|
||||||
| "l31-pointpillars-ravnoves"
|
| "l31-pointpillars-ravnoves"
|
||||||
| "l32-pointpillars-camera-review"
|
| "l32-pointpillars-camera-review"
|
||||||
@@ -76,6 +78,7 @@ export interface AdvancedLaboratoryIndexItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||||
|
"m4-replay-threat",
|
||||||
"l3-pointpillars-visual-audit",
|
"l3-pointpillars-visual-audit",
|
||||||
"l31-pointpillars-ravnoves",
|
"l31-pointpillars-ravnoves",
|
||||||
"l32-pointpillars-camera-review",
|
"l32-pointpillars-camera-review",
|
||||||
@@ -110,6 +113,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||||
|
"m4-replay-threat": "m4-threat-replay",
|
||||||
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
||||||
"l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves",
|
"l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves",
|
||||||
"l32-pointpillars-camera-review": "l32-pointpillars-camera-review",
|
"l32-pointpillars-camera-review": "l32-pointpillars-camera-review",
|
||||||
@@ -151,6 +155,7 @@ export function isAdvancedLaboratoryWorkId(
|
|||||||
|
|
||||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||||
return {
|
return {
|
||||||
|
m4Threat: null,
|
||||||
l3: null,
|
l3: null,
|
||||||
l31: null,
|
l31: null,
|
||||||
l32: null,
|
l32: null,
|
||||||
@@ -273,7 +278,8 @@ export function advancedLaboratoryResultAvailable(
|
|||||||
workId: AdvancedLaboratoryWorkId,
|
workId: AdvancedLaboratoryWorkId,
|
||||||
results: AdvancedLaboratoryResults,
|
results: AdvancedLaboratoryResults,
|
||||||
): boolean {
|
): 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 === "l31-pointpillars-ravnoves" ? results.l31 !== null
|
||||||
: workId === "l32-pointpillars-camera-review" ? results.l32 !== null
|
: workId === "l32-pointpillars-camera-review" ? results.l32 !== null
|
||||||
: workId === "l33-camera-first-detector-review" ? results.l33 !== null
|
: workId === "l33-camera-first-detector-review" ? results.l33 !== null
|
||||||
@@ -317,7 +323,9 @@ export async function fetchAdvancedLaboratoryResult(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<AdvancedLaboratoryResults> {
|
): Promise<AdvancedLaboratoryResults> {
|
||||||
const results = emptyAdvancedLaboratoryResults();
|
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 });
|
results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal });
|
||||||
} else if (workId === "l31-pointpillars-ravnoves") {
|
} else if (workId === "l31-pointpillars-ravnoves") {
|
||||||
results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal });
|
results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal });
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetector
|
|||||||
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay";
|
||||||
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay";
|
||||||
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||||
|
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||||
|
|
||||||
export interface AdvancedLaboratoryResults {
|
export interface AdvancedLaboratoryResults {
|
||||||
|
m4Threat: M4ThreatReplayResult | null;
|
||||||
l3: L3PointPillarsVisualAuditResult | null;
|
l3: L3PointPillarsVisualAuditResult | null;
|
||||||
l31: L31PointPillarsRavnovesResult | null;
|
l31: L31PointPillarsRavnovesResult | null;
|
||||||
l32: L32PointPillarsCameraReviewResult | null;
|
l32: L32PointPillarsCameraReviewResult | null;
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ export class AdvancedLaboratoryContractError extends Error {
|
|||||||
this.name = "AdvancedLaboratoryContractError";
|
this.name = "AdvancedLaboratoryContractError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LaboratoryFetch = (
|
export type LaboratoryFetch = (
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
@@ -968,6 +967,7 @@ export async function fetchAdvancedLaboratoryResults({
|
|||||||
const e39 = settledCatalogValue(settled[7]);
|
const e39 = settledCatalogValue(settled[7]);
|
||||||
const e40 = settledCatalogValue(settled[8]);
|
const e40 = settledCatalogValue(settled[8]);
|
||||||
return {
|
return {
|
||||||
|
m4Threat: null,
|
||||||
l3: null, l31: null,
|
l3: null, l31: null,
|
||||||
l32: null,
|
l32: null,
|
||||||
l33: 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-reporting.css";
|
||||||
@import "./styles/laboratory-evidence-report.css";
|
@import "./styles/laboratory-evidence-report.css";
|
||||||
@import "./styles/e34-temporal-layer.css";
|
@import "./styles/e34-temporal-layer.css";
|
||||||
|
@import "./styles/m4-replay-threat.css";
|
||||||
@import "./styles/e35-degradation-recovery.css";
|
@import "./styles/e35-degradation-recovery.css";
|
||||||
@import "./styles/e30-human-review.css";
|
@import "./styles/e30-human-review.css";
|
||||||
@import "./styles/spatial.css";
|
@import "./styles/spatial.css";
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e46c-video-scene {
|
.recorded-evidence-video-scene {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -55,12 +55,12 @@
|
|||||||
background: var(--nodedc-canvas);
|
background: var(--nodedc-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
.e46c-video-scene > .recorded-media-player {
|
.recorded-evidence-video-scene > .recorded-media-player {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.e46c-video-scene > canvas {
|
.recorded-evidence-video-scene > canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
inset: 0;
|
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 { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult";
|
||||||
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
|
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
|
||||||
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
|
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
|
||||||
|
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
||||||
|
|
||||||
export { isAdvancedLaboratoryWorkId };
|
export { isAdvancedLaboratoryWorkId };
|
||||||
export type { AdvancedLaboratoryWorkId };
|
export type { AdvancedLaboratoryWorkId };
|
||||||
@@ -81,6 +82,9 @@ export function AdvancedLaboratoryResult({
|
|||||||
failedSessionId: string | null;
|
failedSessionId: string | null;
|
||||||
replayError: 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) {
|
if (workId === "l3-pointpillars-visual-audit" && results.l3) {
|
||||||
return <L3PointPillarsResult result={results.l3} />;
|
return <L3PointPillarsResult result={results.l3} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RecordedFmp4Player,
|
|
||||||
type RecordedObservationPlayback,
|
type RecordedObservationPlayback,
|
||||||
} from "../../components/RecordedFmp4Player";
|
} from "../../components/RecordedFmp4Player";
|
||||||
|
import {
|
||||||
|
RecordedEvidenceVideoScene,
|
||||||
|
type RecordedEvidenceBox,
|
||||||
|
} from "../../components/laboratory/RecordedEvidenceVideoScene";
|
||||||
import {
|
import {
|
||||||
selectE46CVideoFrame,
|
selectE46CVideoFrame,
|
||||||
type E46CMotionState,
|
type E46CMotionState,
|
||||||
@@ -11,26 +14,10 @@ import {
|
|||||||
} from "../../core/laboratory/e46cFullReplayWorldTracks";
|
} from "../../core/laboratory/e46cFullReplayWorldTracks";
|
||||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||||
|
|
||||||
function color(
|
function stateTone(state: E46CMotionState): RecordedEvidenceBox["tone"] {
|
||||||
host: HTMLElement,
|
if (state === "dynamic") return "accent";
|
||||||
token: string,
|
if (state === "static") return "success";
|
||||||
fallback: readonly [number, number, number],
|
return "warning";
|
||||||
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 stateLabel(state: E46CMotionState): string {
|
function stateLabel(state: E46CMotionState): string {
|
||||||
@@ -50,97 +37,40 @@ export function E46CRecordedVideoScene({
|
|||||||
playback: RecordedObservationPlayback;
|
playback: RecordedObservationPlayback;
|
||||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
||||||
}) {
|
}) {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const frame = useMemo(
|
const frame = useMemo(
|
||||||
() => selectE46CVideoFrame(overlay.frames, playback.currentSeconds),
|
() => selectE46CVideoFrame(overlay.frames, playback.currentSeconds),
|
||||||
[overlay.frames, playback.currentSeconds],
|
[overlay.frames, playback.currentSeconds],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const boxes = useMemo<readonly RecordedEvidenceBox[]>(() => (
|
||||||
const host = hostRef.current;
|
frame?.objects.map((item) => {
|
||||||
const canvas = canvasRef.current;
|
const identity = `S${item.routeTrackId}${
|
||||||
if (!host || !canvas) return;
|
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
|
||||||
const context = canvas.getContext("2d");
|
}`;
|
||||||
if (!context) return;
|
return {
|
||||||
const render = () => {
|
boxXyxy: item.boxXyxy,
|
||||||
const width = Math.max(host.clientWidth, 1);
|
label: `${identity} · ${item.displayCategory} · ${stateLabel(
|
||||||
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(
|
|
||||||
item.motionState,
|
item.motionState,
|
||||||
)} · ${Math.round(item.score * 100)}%`;
|
)} · ${Math.round(item.score * 100)}%`,
|
||||||
const fontSize = Math.max(9, 10 * scale);
|
tone: stateTone(item.motionState),
|
||||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
dashed: !item.cameraEvidenceCurrent,
|
||||||
const labelWidth = context.measureText(label).width + 8;
|
};
|
||||||
const labelHeight = fontSize + 6;
|
}) ?? []
|
||||||
const labelX = Math.min(
|
), [frame]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="e46c-video-scene" ref={hostRef}>
|
<RecordedEvidenceVideoScene
|
||||||
<RecordedFmp4Player
|
source={source}
|
||||||
source={source}
|
playback={playback}
|
||||||
playback={playback}
|
imageWidth={overlay.imageWidth}
|
||||||
interactive
|
imageHeight={overlay.imageHeight}
|
||||||
prepare
|
boxes={boxes}
|
||||||
onPlaybackChange={onPlaybackChange}
|
ariaLabel={
|
||||||
/>
|
frame
|
||||||
<canvas
|
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
|
||||||
ref={canvasRef}
|
: "E46C recorded video overlay"
|
||||||
role="img"
|
}
|
||||||
aria-label={
|
onPlaybackChange={onPlaybackChange}
|
||||||
frame
|
/>
|
||||||
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
|
|
||||||
: "E46C recorded video overlay"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||||
|
|
||||||
export type LaboratoryProfileId =
|
export type LaboratoryProfileId =
|
||||||
|
| "rig-dual-evidence-virtual-corridor-v1"
|
||||||
| "rig-camera-local-surface-v1"
|
| "rig-camera-local-surface-v1"
|
||||||
| "rig-track-geometry-temporal-v1"
|
| "rig-track-geometry-temporal-v1"
|
||||||
| "rig-ravnoves-perception-gate-v1"
|
| "rig-ravnoves-perception-gate-v1"
|
||||||
@@ -56,6 +57,13 @@ interface KnownWorkDefinition {
|
|||||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||||
|
|
||||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
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": {
|
"e28-local-surface": {
|
||||||
profileId: "rig-camera-local-surface-v1",
|
profileId: "rig-camera-local-surface-v1",
|
||||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function mergeResults(
|
|||||||
next: AdvancedLaboratoryResults,
|
next: AdvancedLaboratoryResults,
|
||||||
): AdvancedLaboratoryResults {
|
): AdvancedLaboratoryResults {
|
||||||
return {
|
return {
|
||||||
|
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||||
l3: next.l3 ?? current.l3,
|
l3: next.l3 ?? current.l3,
|
||||||
l31: next.l31 ?? current.l31,
|
l31: next.l31 ?? current.l31,
|
||||||
l32: next.l32 ?? current.l32,
|
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);
|
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 () => {
|
test("E46C viewer opens with full VIDEO and reuses the shared recorded overlay scene", async () => {
|
||||||
const [visual, videoScene, player] = await Promise.all([
|
const [visual, videoScene, sharedScene, player] = await Promise.all([
|
||||||
readFile(
|
readFile(
|
||||||
new URL(
|
new URL(
|
||||||
"../src/workspaces/laboratory/E46CFullReplayWorldTracksVisual.tsx",
|
"../src/workspaces/laboratory/E46CFullReplayWorldTracksVisual.tsx",
|
||||||
@@ -107,14 +107,22 @@ test("E46C viewer opens with full VIDEO and reuses the admitted recorded player"
|
|||||||
),
|
),
|
||||||
"utf8",
|
"utf8",
|
||||||
),
|
),
|
||||||
|
readFile(
|
||||||
|
new URL(
|
||||||
|
"../src/components/laboratory/RecordedEvidenceVideoScene.tsx",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
readFile(new URL("../src/components/RecordedFmp4Player.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, /useState<E46CViewMode>\("video"\)/);
|
||||||
assert.match(visual, /\{ value: "video", label: "VIDEO" \}/);
|
assert.match(visual, /\{ value: "video", label: "VIDEO" \}/);
|
||||||
assert.match(visual, /replayObservationSession\(overlay\.recordedSourceSessionId/);
|
assert.match(visual, /replayObservationSession\(overlay\.recordedSourceSessionId/);
|
||||||
assert.match(videoScene, /<RecordedFmp4Player/);
|
assert.match(videoScene, /<RecordedEvidenceVideoScene/);
|
||||||
assert.match(videoScene, /selectE46CVideoFrame/);
|
assert.match(videoScene, /selectE46CVideoFrame/);
|
||||||
|
assert.match(sharedScene, /<RecordedFmp4Player/);
|
||||||
assert.match(player, /requestVideoFrameCallback/);
|
assert.match(player, /requestVideoFrameCallback/);
|
||||||
assert.match(player, /controls=\{interactive\}/);
|
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