diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx new file mode 100644 index 0000000..a352cd1 --- /dev/null +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -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(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const contentRef = useRef(null); + const [renderError, setRenderError] = useState(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 ( +
+
+ {renderError ?

{renderError}

: null} +
+
+ + ЛКМ · вращение · колесо · масштаб · ПКМ · панорама +
+
+ Угроза + Вне коридора + Неизвестно + LiDAR context +
+
+ ); +} diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx new file mode 100644 index 0000000..e0ba202 --- /dev/null +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx @@ -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(null); + const canvasRef = useRef(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 ( +
+ + +
+ ); +} diff --git a/apps/control-station/src/core/laboratory/advancedIndex.ts b/apps/control-station/src/core/laboratory/advancedIndex.ts index 062ead9..415df5b 100644 --- a/apps/control-station/src/core/laboratory/advancedIndex.ts +++ b/apps/control-station/src/core/laboratory/advancedIndex.ts @@ -35,8 +35,10 @@ import { fetchE46GRectifiedDetectorBakeoff } from "./e46gRectifiedDetectorBakeof import { fetchE46HFullRectifiedFrontReplay } from "./e46hFullRectifiedFrontReplay"; import { fetchE46IGroundingDinoFullReplay } from "./e46iGroundingDinoFullReplay"; import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime"; +import { fetchM4ThreatReplayResult } from "./m4ReplayThreat"; export type AdvancedLaboratoryWorkId = + | "m4-replay-threat" | "l3-pointpillars-visual-audit" | "l31-pointpillars-ravnoves" | "l32-pointpillars-camera-review" @@ -76,6 +78,7 @@ export interface AdvancedLaboratoryIndexItem { } const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ + "m4-replay-threat", "l3-pointpillars-visual-audit", "l31-pointpillars-ravnoves", "l32-pointpillars-camera-review", @@ -110,6 +113,7 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [ ]; const RESULT_PREFIX: Readonly> = { + "m4-replay-threat": "m4-threat-replay", "l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit", "l31-pointpillars-ravnoves": "l31-pointpillars-ravnoves", "l32-pointpillars-camera-review": "l32-pointpillars-camera-review", @@ -151,6 +155,7 @@ export function isAdvancedLaboratoryWorkId( export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { return { + m4Threat: null, l3: null, l31: null, l32: null, @@ -273,7 +278,8 @@ export function advancedLaboratoryResultAvailable( workId: AdvancedLaboratoryWorkId, results: AdvancedLaboratoryResults, ): boolean { - return workId === "l3-pointpillars-visual-audit" ? results.l3 !== null + return workId === "m4-replay-threat" ? results.m4Threat !== null + : workId === "l3-pointpillars-visual-audit" ? results.l3 !== null : workId === "l31-pointpillars-ravnoves" ? results.l31 !== null : workId === "l32-pointpillars-camera-review" ? results.l32 !== null : workId === "l33-camera-first-detector-review" ? results.l33 !== null @@ -317,7 +323,9 @@ export async function fetchAdvancedLaboratoryResult( } = {}, ): Promise { const results = emptyAdvancedLaboratoryResults(); - if (workId === "l3-pointpillars-visual-audit") { + if (workId === "m4-replay-threat") { + results.m4Threat = await fetchM4ThreatReplayResult({ fetcher, signal }); + } else if (workId === "l3-pointpillars-visual-audit") { results.l3 = await fetchL3PointPillarsVisualAudit({ fetcher, signal }); } else if (workId === "l31-pointpillars-ravnoves") { results.l31 = await fetchL31PointPillarsRavnoves({ fetcher, signal }); diff --git a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts index 0680fcd..fba83e1 100644 --- a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -31,8 +31,10 @@ import type { E46GRectifiedDetectorBakeoffResult } from "./e46gRectifiedDetector import type { E46HFullRectifiedFrontReplayResult } from "./e46hFullRectifiedFrontReplay"; import type { E46IGroundingDinoFullReplayResult } from "./e46iGroundingDinoFullReplay"; import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime"; +import type { M4ThreatReplayResult } from "./m4ReplayThreat"; export interface AdvancedLaboratoryResults { + m4Threat: M4ThreatReplayResult | null; l3: L3PointPillarsVisualAuditResult | null; l31: L31PointPillarsRavnovesResult | null; l32: L32PointPillarsCameraReviewResult | null; diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 6c438d6..dabc09c 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -235,7 +235,6 @@ export class AdvancedLaboratoryContractError extends Error { this.name = "AdvancedLaboratoryContractError"; } } - export type LaboratoryFetch = ( input: RequestInfo | URL, init?: RequestInit, @@ -968,6 +967,7 @@ export async function fetchAdvancedLaboratoryResults({ const e39 = settledCatalogValue(settled[7]); const e40 = settledCatalogValue(settled[8]); return { + m4Threat: null, l3: null, l31: null, l32: null, l33: null, diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts new file mode 100644 index 0000000..cf21708 --- /dev/null +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -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; + 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>; + }; + 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; +} + +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; +class M4ThreatContractError extends Error {} +const object = (value: unknown, label: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new M4ThreatContractError(`${label}: ожидался объект.`); + } + return value as Record; +}; +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 = ( + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 25d56fe..4c7a3d9 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -9,6 +9,7 @@ @import "./styles/laboratory-reporting.css"; @import "./styles/laboratory-evidence-report.css"; @import "./styles/e34-temporal-layer.css"; +@import "./styles/m4-replay-threat.css"; @import "./styles/e35-degradation-recovery.css"; @import "./styles/e30-human-review.css"; @import "./styles/spatial.css"; diff --git a/apps/control-station/src/styles/l3-pointpillars-visual-audit.css b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css index 13b0918..e3bdccd 100644 --- a/apps/control-station/src/styles/l3-pointpillars-visual-audit.css +++ b/apps/control-station/src/styles/l3-pointpillars-visual-audit.css @@ -46,7 +46,7 @@ user-select: none; } -.e46c-video-scene { +.recorded-evidence-video-scene { position: relative; width: 100%; height: 100%; @@ -55,12 +55,12 @@ background: var(--nodedc-canvas); } -.e46c-video-scene > .recorded-media-player { +.recorded-evidence-video-scene > .recorded-media-player { position: absolute; inset: 0; } -.e46c-video-scene > canvas { +.recorded-evidence-video-scene > canvas { position: absolute; z-index: 2; inset: 0; diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css new file mode 100644 index 0000000..04fadaa --- /dev/null +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -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; + } +} diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index 56ea6de..c240c4e 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -39,6 +39,7 @@ import { E46GRectifiedDetectorBakeoffResultView } from "./E46GRectifiedDetectorB import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult"; import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult"; import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult"; +import { M4ReplayThreatResultView } from "./M4ReplayThreatResult"; export { isAdvancedLaboratoryWorkId }; export type { AdvancedLaboratoryWorkId }; @@ -81,6 +82,9 @@ export function AdvancedLaboratoryResult({ failedSessionId: string | null; replayError: string | null; }) { + if (workId === "m4-replay-threat" && results.m4Threat) { + return ; + } if (workId === "l3-pointpillars-visual-audit" && results.l3) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/E46CRecordedVideoScene.tsx b/apps/control-station/src/workspaces/laboratory/E46CRecordedVideoScene.tsx index 6cacb9c..c884362 100644 --- a/apps/control-station/src/workspaces/laboratory/E46CRecordedVideoScene.tsx +++ b/apps/control-station/src/workspaces/laboratory/E46CRecordedVideoScene.tsx @@ -1,9 +1,12 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useMemo } from "react"; import { - RecordedFmp4Player, type RecordedObservationPlayback, } from "../../components/RecordedFmp4Player"; +import { + RecordedEvidenceVideoScene, + type RecordedEvidenceBox, +} from "../../components/laboratory/RecordedEvidenceVideoScene"; import { selectE46CVideoFrame, type E46CMotionState, @@ -11,26 +14,10 @@ import { } from "../../core/laboratory/e46cFullReplayWorldTracks"; import type { ObservationSourceDescriptor } from "../../core/runtime/contracts"; -function color( - host: HTMLElement, - token: string, - fallback: readonly [number, number, number], - alpha = 1, -): string { - const channels = getComputedStyle(host) - .getPropertyValue(token) - .trim() - .match(/[\d.]+/g) - ?.slice(0, 3) - .map(Number); - const [red, green, blue] = channels?.length === 3 ? channels : fallback; - return `rgba(${red}, ${green}, ${blue}, ${alpha})`; -} - -function stateColor(host: HTMLElement, state: E46CMotionState): string { - if (state === "dynamic") return color(host, "--nodedc-accent-rgb", [232, 56, 126]); - if (state === "static") return color(host, "--nodedc-success-rgb", [181, 255, 90]); - return color(host, "--nodedc-warning-rgb", [255, 197, 92]); +function stateTone(state: E46CMotionState): RecordedEvidenceBox["tone"] { + if (state === "dynamic") return "accent"; + if (state === "static") return "success"; + return "warning"; } function stateLabel(state: E46CMotionState): string { @@ -50,97 +37,40 @@ export function E46CRecordedVideoScene({ playback: RecordedObservationPlayback; onPlaybackChange: (playback: RecordedObservationPlayback) => void; }) { - const hostRef = useRef(null); - const canvasRef = useRef(null); const frame = useMemo( () => selectE46CVideoFrame(overlay.frames, playback.currentSeconds), [overlay.frames, playback.currentSeconds], ); - useEffect(() => { - const host = hostRef.current; - const canvas = canvasRef.current; - if (!host || !canvas) return; - const context = canvas.getContext("2d"); - if (!context) return; - const render = () => { - const width = Math.max(host.clientWidth, 1); - const height = Math.max(host.clientHeight, 1); - const pixelRatio = Math.min(window.devicePixelRatio, 1.5); - canvas.width = Math.round(width * pixelRatio); - canvas.height = Math.round(height * pixelRatio); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); - context.clearRect(0, 0, width, height); - if (!frame) return; - - const scale = Math.min( - width / overlay.imageWidth, - height / overlay.imageHeight, - ); - const drawWidth = overlay.imageWidth * scale; - const drawHeight = overlay.imageHeight * scale; - const offsetX = (width - drawWidth) / 2; - const offsetY = (height - drawHeight) / 2; - for (const item of frame.objects) { - const [left, top, right, bottom] = item.boxXyxy; - const x = offsetX + left * scale; - const y = offsetY + top * scale; - const boxWidth = (right - left) * scale; - const boxHeight = (bottom - top) * scale; - const stroke = stateColor(host, item.motionState); - context.strokeStyle = stroke; - context.lineWidth = Math.max(1.5, 2 * scale); - context.setLineDash(item.cameraEvidenceCurrent ? [] : [5, 4]); - context.strokeRect(x, y, boxWidth, boxHeight); - context.setLineDash([]); - - const identity = `S${item.routeTrackId}${ - item.worldTrackId === null ? "" : `→W${item.worldTrackId}` - }`; - const label = `${identity} · ${item.displayCategory} · ${stateLabel( + const boxes = useMemo(() => ( + frame?.objects.map((item) => { + const identity = `S${item.routeTrackId}${ + item.worldTrackId === null ? "" : `→W${item.worldTrackId}` + }`; + return { + boxXyxy: item.boxXyxy, + label: `${identity} · ${item.displayCategory} · ${stateLabel( item.motionState, - )} · ${Math.round(item.score * 100)}%`; - const fontSize = Math.max(9, 10 * scale); - context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`; - const labelWidth = context.measureText(label).width + 8; - const labelHeight = fontSize + 6; - const labelX = Math.min( - offsetX + drawWidth - labelWidth, - Math.max(offsetX, x), - ); - const labelY = Math.max(offsetY, y - labelHeight); - context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9); - context.fillRect(labelX, labelY, labelWidth, labelHeight); - context.fillStyle = stroke; - context.fillText(label, labelX + 4, labelY + fontSize + 1); - } - }; - const observer = new ResizeObserver(render); - observer.observe(host); - render(); - return () => observer.disconnect(); - }, [frame, overlay.imageHeight, overlay.imageWidth]); + )} · ${Math.round(item.score * 100)}%`, + tone: stateTone(item.motionState), + dashed: !item.cameraEvidenceCurrent, + }; + }) ?? [] + ), [frame]); return ( -
- - -
+ ); } diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatResult.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatResult.tsx new file mode 100644 index 0000000..4406b6a --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatResult.tsx @@ -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 ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx new file mode 100644 index 0000000..59ef8f5 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -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("video"); + const [expanded, setExpanded] = useState(false); + const [index, setIndex] = useState([]); + const [ordinal, setOrdinal] = useState(1); + const [frame, setFrame] = useState(null); + const [sampleLoading, setSampleLoading] = useState(true); + const [sampleError, setSampleError] = useState(null); + const [videoOverlay, setVideoOverlay] = useState(null); + const [videoSource, setVideoSource] = useState(null); + const [videoLoading, setVideoLoading] = useState(false); + const [videoError, setVideoError] = useState(null); + const [videoPlayback, setVideoPlayback] = useState({ + 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" ? ( +
+
+ seekVideo(videoPlayback.currentSeconds - 5)}> + + + seekVideo(videoPlayback.currentSeconds + 5)}> + + +
+ ({ + 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))} + /> +
+ ); + + const overlay = mode === "video" && videoOverlay ? ( +
+
+ RAVNOVES00 · recorded RIGHT + + +{(videoPlayback.currentSeconds - videoOverlay.timelineStartSeconds).toFixed(1)} с + {activeVideoFrame ? ` · frame ${activeVideoFrame.frameIndex}` : ""} + + {videoPlayback.playing ? "воспроизведение" : "пауза / seek"} +
+
+ Camera evidence + {activeVideoFrame?.cameraProposals.length ?? 0} рамок · distance при LiDAR support + пунктир = camera-only · всегда unknown +
+
+ Replay decision + + {activeVideoFrame?.decisionCounts.threat ?? 0} threat · {activeVideoFrame?.decisionCounts["not-threat"] ?? 0} clear · {activeVideoFrame?.decisionCounts.unknown ?? 0} unknown + + REPLAY-SIMULATED · не live и не safety authority +
+
+ ) : frame ? ( +
+
+ RAVNOVES00 · exact replay sample + frame {frame.sequence} · sample {frame.ordinal}/32 + {(frame.sourceTimeNs / 1_000_000_000).toFixed(3)} с · {selectedItem?.frameId} +
+
+ Dual evidence + {frame.metricObstacles.length} metric · {frame.cameraProposals.length} camera + {frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} LiDAR points shown +
+
+ Virtual corridor + {threatObstacles.length} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`} + {frame.corridor.forwardLengthM} м · body {frame.rig.lengthM}×{frame.rig.widthM} м · REPLAY-SIMULATED +
+
+ ) : undefined; + + let content; + if (mode === "video" || mode === "camera") { + content = videoLoading ? ( +
+
+ ) : videoError || !videoOverlay || !videoSource ? ( +
+ + {videoError ?? "Видео-доказательство M4.6 недоступно."} +
+ ) : ( + + ); + } else { + content = sampleLoading ? ( +
+
+ ) : sampleError || !frame ? ( +
+ + {sampleError ?? "Метрический visual M4.6 недоступен."} +
+ ) : ( + ({ + 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 ( +
+ + {content} + +
+ ); +} diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index 4a19245..0a597e8 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -6,6 +6,7 @@ import type { import type { ObservationSessionSummary } from "../../core/observation/sessionArchive"; export type LaboratoryProfileId = + | "rig-dual-evidence-virtual-corridor-v1" | "rig-camera-local-surface-v1" | "rig-track-geometry-temporal-v1" | "rig-ravnoves-perception-gate-v1" @@ -56,6 +57,13 @@ interface KnownWorkDefinition { const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг"; const KNOWN_WORKS: Readonly, KnownWorkDefinition>> = { + "m4-replay-threat": { + profileId: "rig-dual-evidence-virtual-corridor-v1", + profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`, + experimentId: "m4-ravnoves00-dual-evidence-threat", + experimentName: "RAVNOVES00 dual-evidence threat qualification", + variantName: "M4.6 · virtual corridor replay · VIDEO/CAMERA/3D", + }, "e28-local-surface": { profileId: "rig-camera-local-surface-v1", profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`, diff --git a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts index d5bbd88..ab8fc46 100644 --- a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts +++ b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts @@ -18,6 +18,7 @@ function mergeResults( next: AdvancedLaboratoryResults, ): AdvancedLaboratoryResults { return { + m4Threat: next.m4Threat ?? current.m4Threat, l3: next.l3 ?? current.l3, l31: next.l31 ?? current.l31, l32: next.l32 ?? current.l32, diff --git a/apps/control-station/test/e46cVideoReplay.test.mjs b/apps/control-station/test/e46cVideoReplay.test.mjs index 585046e..7203665 100644 --- a/apps/control-station/test/e46cVideoReplay.test.mjs +++ b/apps/control-station/test/e46cVideoReplay.test.mjs @@ -91,8 +91,8 @@ test("E46C decodes the complete path-free temporal video overlay", async () => { assert.equal(selectE46CVideoFrame(overlay.frames, overlay.frames[20].sessionSeconds).frameIndex, 20); }); -test("E46C viewer opens with full VIDEO and reuses the admitted recorded player", async () => { - const [visual, videoScene, player] = await Promise.all([ +test("E46C viewer opens with full VIDEO and reuses the shared recorded overlay scene", async () => { + const [visual, videoScene, sharedScene, player] = await Promise.all([ readFile( new URL( "../src/workspaces/laboratory/E46CFullReplayWorldTracksVisual.tsx", @@ -107,14 +107,22 @@ test("E46C viewer opens with full VIDEO and reuses the admitted recorded player" ), "utf8", ), + readFile( + new URL( + "../src/components/laboratory/RecordedEvidenceVideoScene.tsx", + import.meta.url, + ), + "utf8", + ), readFile(new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8"), ]); assert.match(visual, /useState\("video"\)/); assert.match(visual, /\{ value: "video", label: "VIDEO" \}/); assert.match(visual, /replayObservationSession\(overlay\.recordedSourceSessionId/); - assert.match(videoScene, / { + 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, /