From 15be69809744d48e7c0023c34591d5f019d5603e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 25 Aug 2026 16:44:52 +0300 Subject: [PATCH] feat(ui): add M4.8S replay with LiDAR overlay --- .../laboratory/RecordedEvidenceImageScene.tsx | 13 + .../RecordedEvidencePointCloudOverlay.tsx | 76 +++ .../laboratory/RecordedEvidenceVideoScene.tsx | 13 + .../src/core/laboratory/advancedIndex.ts | 9 + .../laboratory/advancedLaboratoryResults.ts | 2 + .../src/core/laboratory/advancedResults.ts | 2 +- .../core/laboratory/m48sFixedClassDetector.ts | 569 ++++++++++++++++++ .../src/core/laboratory/m4ReplayThreat.ts | 92 ++- .../laboratory/AdvancedLaboratoryResult.tsx | 4 + .../M48SFixedClassDetectorResult.tsx | 103 ++++ .../M48SFixedClassDetectorVisual.tsx | 168 ++++++ .../laboratory/M4ReplayThreatVisual.tsx | 81 ++- .../laboratory/laboratoryArchiveProfiles.ts | 7 + .../useAdvancedLaboratoryCatalog.ts | 1 + .../laboratory/useM4ThreatTimeline.ts | 11 +- .../test/m48sFixedClassDetector.test.mjs | 226 +++++++ .../test/m4ReplayThreat.test.mjs | 102 +++- 17 files changed, 1448 insertions(+), 31 deletions(-) create mode 100644 apps/control-station/src/components/laboratory/RecordedEvidencePointCloudOverlay.tsx create mode 100644 apps/control-station/src/core/laboratory/m48sFixedClassDetector.ts create mode 100644 apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorResult.tsx create mode 100644 apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorVisual.tsx create mode 100644 apps/control-station/test/m48sFixedClassDetector.test.mjs diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx index 755aa28..04759d4 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceImageScene.tsx @@ -9,6 +9,10 @@ import { RecordedEvidenceSemanticMaskOverlay, type RecordedEvidenceSemanticOverlay, } from "./RecordedEvidenceSemanticMaskOverlay"; +import { + RecordedEvidencePointCloudOverlay, + type RecordedEvidencePointCloudOverlayData, +} from "./RecordedEvidencePointCloudOverlay"; export function RecordedEvidenceImageScene({ src, @@ -16,6 +20,7 @@ export function RecordedEvidenceImageScene({ imageHeight, boxes, semanticOverlay, + pointCloudOverlay, ariaLabel, }: { src: string; @@ -23,6 +28,7 @@ export function RecordedEvidenceImageScene({ imageHeight: number; boxes: readonly RecordedEvidenceBox[]; semanticOverlay?: RecordedEvidenceSemanticOverlay; + pointCloudOverlay?: RecordedEvidencePointCloudOverlayData; ariaLabel: string; }) { const [state, setState] = useState<"loading" | "ready" | "error">("loading"); @@ -47,6 +53,13 @@ export function RecordedEvidenceImageScene({ imageHeight={imageHeight} /> ) : null} + {pointCloudOverlay ? ( + + ) : null} (null); + + useEffect(() => { + const canvas = canvasRef.current; + const host = canvas?.parentElement; + if (!canvas || !host) return; + const context = canvas.getContext("2d"); + if (!context) return; + + const render = () => { + const width = Math.max(host.clientWidth, 1); + const height = Math.max(host.clientHeight, 1); + const pixelRatio = Math.min(window.devicePixelRatio, 1.5); + canvas.width = Math.round(width * pixelRatio); + canvas.height = Math.round(height * pixelRatio); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + context.clearRect(0, 0, width, height); + + const scale = Math.min(width / imageWidth, height / imageHeight); + const offsetX = (width - imageWidth * scale) / 2; + const offsetY = (height - imageHeight * scale) / 2; + const radius = Math.max(0.8, Math.min(2.2, scale * 1.45)); + for (const [imageX, imageY, depthM] of overlay.pointsXyd) { + context.beginPath(); + context.arc( + offsetX + imageX * scale, + offsetY + imageY * scale, + radius, + 0, + Math.PI * 2, + ); + context.fillStyle = depthColor(depthM); + context.fill(); + } + }; + + const observer = new ResizeObserver(render); + observer.observe(host); + render(); + return () => observer.disconnect(); + }, [imageHeight, imageWidth, overlay]); + + return ; +} diff --git a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx index 851f8b9..d96f046 100644 --- a/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx +++ b/apps/control-station/src/components/laboratory/RecordedEvidenceVideoScene.tsx @@ -12,6 +12,10 @@ import { RecordedEvidenceSemanticMaskOverlay, type RecordedEvidenceSemanticOverlay, } from "./RecordedEvidenceSemanticMaskOverlay"; +import { + RecordedEvidencePointCloudOverlay, + type RecordedEvidencePointCloudOverlayData, +} from "./RecordedEvidencePointCloudOverlay"; export type { RecordedEvidenceBox, RecordedEvidenceBoxTone }; @@ -22,6 +26,7 @@ export function RecordedEvidenceVideoScene({ imageHeight, boxes, semanticOverlay, + pointCloudOverlay, ariaLabel, interactive = true, segmentSequence, @@ -35,6 +40,7 @@ export function RecordedEvidenceVideoScene({ imageHeight: number; boxes: readonly RecordedEvidenceBox[]; semanticOverlay?: RecordedEvidenceSemanticOverlay; + pointCloudOverlay?: RecordedEvidencePointCloudOverlayData; ariaLabel: string; interactive?: boolean; segmentSequence?: number; @@ -61,6 +67,13 @@ export function RecordedEvidenceVideoScene({ imageHeight={imageHeight} /> ) : null} + {pointCloudOverlay ? ( + + ) : null} > = { "m48-object-centric-quality": "m48-object-quality-(?:pack|result)", "m48-small-static-passage-regression": "m48-small-static-passage-regression", + "m48s-fixed-class-detector": "m48s-fixed-class-detector-lab", "m47-reference-graph-shadow": "m47-reference-graph-lab", "m4-replay-threat": "m4-threat-replay", "l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit", @@ -176,6 +180,7 @@ export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults { m47Graph: null, m48: null, m48SmallStatic: null, + m48s: null, m4Threat: null, l3: null, l31: null, @@ -302,6 +307,7 @@ export function advancedLaboratoryResultAvailable( ): boolean { return workId === "m48-object-centric-quality" ? results.m48 !== null : workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null + : workId === "m48s-fixed-class-detector" ? results.m48s !== null : workId === "m47-reference-graph-shadow" ? results.m47Graph !== null : workId === "m4-replay-threat" ? results.m4Threat !== null : workId === "l3-pointpillars-visual-audit" ? results.l3 !== null @@ -357,6 +363,9 @@ export async function fetchAdvancedLaboratoryResult( } else if (workId === "m48-small-static-passage-regression") { if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R1 regression identity не выбрана."); results.m48SmallStatic = await fetchM48SmallStaticRegression(resultId, { fetcher, signal }); + } else if (workId === "m48s-fixed-class-detector") { + if (!resultId) throw new AdvancedLaboratoryContractError("M4.8S LAB identity не выбрана."); + results.m48s = await fetchM48SFixedClassDetectorResult(resultId, { fetcher, signal }); } else if (workId === "m47-reference-graph-shadow") { if (!resultId) { throw new AdvancedLaboratoryContractError("M4.7 LAB identity не выбрана."); diff --git a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts index c5684fe..583b3ef 100644 --- a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -36,11 +36,13 @@ import type { M4ThreatReplayResult } from "./m4ReplayThreat"; import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph"; import type { M48AdvancedResult } from "./m48ObjectCentricQuality"; import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression"; +import type { M48SFixedClassDetectorResult } from "./m48sFixedClassDetector"; export interface AdvancedLaboratoryResults { m47Graph: M47ReferenceGraphLabResult | null; m48: M48AdvancedResult | null; m48SmallStatic: M48SmallStaticRegressionResult | null; + m48s: M48SFixedClassDetectorResult | null; m4Threat: M4ThreatReplayResult | null; l3: L3PointPillarsVisualAuditResult | null; l31: L31PointPillarsRavnovesResult | null; diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 27784da..2b88486 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -967,7 +967,7 @@ export async function fetchAdvancedLaboratoryResults({ const e39 = settledCatalogValue(settled[7]); const e40 = settledCatalogValue(settled[8]); return { - m47Graph: null, m48: null, m48SmallStatic: null, m4Threat: null, + m47Graph: null, m48: null, m48SmallStatic: null, m48s: null, m4Threat: null, l3: null, l31: null, l32: null, l33: null, diff --git a/apps/control-station/src/core/laboratory/m48sFixedClassDetector.ts b/apps/control-station/src/core/laboratory/m48sFixedClassDetector.ts new file mode 100644 index 0000000..454b216 --- /dev/null +++ b/apps/control-station/src/core/laboratory/m48sFixedClassDetector.ts @@ -0,0 +1,569 @@ +import type { LaboratoryFetch } from "./advancedResults"; + +const RESULT_ID = /^m48s-fixed-class-detector-lab-[a-f0-9]{64}$/; +const FRAME_ID = /^[0-9]{6}$/; +const MODES = ["source", "yolox", "dfine", "rf-detr"] as const; +const DETECTOR_MODES = ["yolox", "dfine", "rf-detr"] as const; +const RESULT_STATUSES = [ + "detector-load-passed-reference-graph-shadow-only", + "complete-reference-graph-shadow-passed-production-not-authorized", +] as const; + +export type M48SDetectorMode = typeof MODES[number]; +export type M48SDetectorModel = typeof DETECTOR_MODES[number]; + +export interface M48SAuthority { + actuationAllowed: false; + candidateAccepted: false; + commandsEnabled: false; + groundTruth: false; + navigationOrSafetyAccepted: false; +} + +export interface M48SMethodComponent { + kind: "source" | "tool" | "model" | "algorithm" | "runtime"; + name: string; + version: string; + role: string; + identitySha256: string; +} + +export interface M48SMethod { + completeness: "complete"; + executionClass: "ai-inference"; + pipelineId: string; + components: readonly M48SMethodComponent[]; +} + +export interface M48SDetectorCandidate { + id: M48SDetectorModel; + label: string; + providerId: string; + capacityFps: number; + p95Ms: number; + frame253DogDetected: boolean; + frame253DogScore: number | null; + selected: boolean; +} + +export interface M48SFrameDescriptor { + frameId: string; + sourceSequence: number; + counts: Readonly>; +} + +export interface M48SIntegratedWorldState { + durationSeconds: number; + sourceFramesAdmitted: number; + deliveredWorldStates: number; + supersededFrames: number; + effectiveWorldStateFps: number; + worldStateCompletionAgeP95Ms: number; + worldStateCompletionAgeP99Ms: number; + worldStateCompletionAgeMaximumMs: number; + localObstacleMapOutputAgeP95Ms: number; + queueHighWatermarks: Readonly>; + queueCapacity: number; + gpuUtilizationMeanPercent: number; + gpuUtilizationMaximumPercent: number; + gpuMemoryMaximumMib: number; + gpuPowerMaximumW: number; + gpuTemperatureMaximumC: number; + uniqueComponentCount: number; + multiFrameComponentCount: number; + maximumComponentPublications: number; + duplicateComponentIdsWithinFrame: number; + advisoryFamilyCounts: Readonly>; + semanticHintCounts: Readonly>; + motionCounts: Readonly>; + additionalInferencePasses: number; + failures: number; +} + +export interface M48SFixedClassDetectorResult { + resultId: string; + createdAtUtc: string; + status: typeof RESULT_STATUSES[number]; + boundedQuestionAccepted: true; + groundTruth: false; + source: { + sourceSessionId: "RAVNOVES00"; + cameraSourceId: "sensor.camera.right"; + cameraRaster: readonly [800, 600]; + evidenceFrameCount: number; + }; + configuration: { + comparisonThreshold: 0.5; + displayModes: readonly M48SDetectorMode[]; + singleInferencePerFrame: true; + geometryOwnsStaticOccupancy: true; + unknownStationaryResponse: "route-around"; + unknownMovingResponse: "conservative-risk"; + }; + method: M48SMethod; + metrics: { + candidates: readonly M48SDetectorCandidate[]; + detectorLoad: { + durationSeconds: number; + sourceFramesConsumed: number; + sourceFrameReplacements: number; + effectiveConsumedFps: number; + endToEndP95Ms: number; + completionAgeP95Ms: number; + gpuUtilizationMeanPercent: number; + gpuUtilizationMaximumPercent: number; + gpuMemoryMaximumMib: number; + queueMaximumDepth: number; + queueCapacity: number; + failures: number; + }; + integratedWorldState: M48SIntegratedWorldState | null; + }; + decision: { + selectedCandidate: "rf-detr"; + readyForReferenceGraphShadow: true; + integratedWorldStateGateEvaluated: boolean; + integratedWorldStateGatePassed: boolean; + detectorReplacementAuthorized: false; + productionAccepted: false; + }; + limitations: readonly string[]; + authority: M48SAuthority; + frames: readonly M48SFrameDescriptor[]; +} + +export interface M48SDetection { + label: string; + score: number; + bboxXyxy: readonly [number, number, number, number]; +} + +export interface M48SDetectorFrame { + resultId: string; + frameId: string; + sourceSequence: number; + cameraUrl: string; + imageWidth: 800; + imageHeight: 600; + comparisonThreshold: 0.5; + detections: Readonly>; + groundTruthAvailable: false; + authority: M48SAuthority; +} + +export class M48SFixedClassDetectorContractError extends Error {} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new M48SFixedClassDetectorContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function arrayValue(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) { + throw new M48SFixedClassDetectorContractError(`${label}: ожидался список.`); + } + return value; +} + +function exact(value: unknown, expected: string | number | boolean, label: string): void { + if (value !== expected) { + throw new M48SFixedClassDetectorContractError(`${label}: нарушен контракт.`); + } +} + +function textValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new M48SFixedClassDetectorContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new M48SFixedClassDetectorContractError(`${label}: ожидалось число.`); + } + return value; +} + +function integerValue(value: unknown, label: string): number { + const result = numberValue(value, label); + if (!Number.isInteger(result) || result < 0) { + throw new M48SFixedClassDetectorContractError(`${label}: ожидалось целое число.`); + } + return result; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new M48SFixedClassDetectorContractError(`${label}: ожидался флаг.`); + } + return value; +} + +function enumValue(value: unknown, allowed: readonly T[], label: string): T { + if (typeof value !== "string" || !allowed.includes(value as T)) { + throw new M48SFixedClassDetectorContractError(`${label}: неизвестное значение.`); + } + return value as T; +} + +function authorityValue(value: unknown, label: string): M48SAuthority { + const authority = objectValue(value, label); + exact(authority.actuation_allowed, false, `${label}.actuation_allowed`); + exact(authority.candidate_accepted, false, `${label}.candidate_accepted`); + exact(authority.commands_enabled, false, `${label}.commands_enabled`); + exact(authority.ground_truth, false, `${label}.ground_truth`); + exact( + authority.navigation_or_safety_accepted, + false, + `${label}.navigation_or_safety_accepted`, + ); + return { + actuationAllowed: false, + candidateAccepted: false, + commandsEnabled: false, + groundTruth: false, + navigationOrSafetyAccepted: false, + }; +} + +function methodValue(value: unknown): M48SMethod { + const method = objectValue(value, "M4.8S.method"); + exact(method.schema_version, "missioncore.laboratory-method/v1", "M4.8S.method.schema_version"); + exact(method.completeness, "complete", "M4.8S.method.completeness"); + exact(method.execution_class, "ai-inference", "M4.8S.method.execution_class"); + return { + completeness: "complete", + executionClass: "ai-inference", + pipelineId: textValue(method.pipeline_id, "M4.8S.method.pipeline_id"), + components: arrayValue(method.components, "M4.8S.method.components").map((value, index) => { + const component = objectValue(value, `M4.8S.method.components[${index}]`); + return { + kind: enumValue( + component.kind, + ["source", "tool", "model", "algorithm", "runtime"] as const, + `M4.8S.method.components[${index}].kind`, + ), + name: textValue(component.name, `M4.8S.method.components[${index}].name`), + version: textValue(component.version, `M4.8S.method.components[${index}].version`), + role: textValue(component.role, `M4.8S.method.components[${index}].role`), + identitySha256: textValue( + component.identity_sha256, + `M4.8S.method.components[${index}].identity_sha256`, + ), + }; + }), + }; +} + +function countsValue(value: unknown, label: string): Readonly> { + const counts = objectValue(value, label); + return { + yolox: integerValue(counts.yolox, `${label}.yolox`), + dfine: integerValue(counts.dfine, `${label}.dfine`), + "rf-detr": integerValue(counts["rf-detr"], `${label}.rf-detr`), + }; +} + +function descriptorValue(value: unknown): M48SFrameDescriptor { + const frame = objectValue(value, "M4.8S.frame"); + const frameId = textValue(frame.frame_id, "M4.8S.frame.frame_id"); + if (!FRAME_ID.test(frameId)) { + throw new M48SFixedClassDetectorContractError("M4.8S.frame.frame_id: нарушена идентичность."); + } + return { + frameId, + sourceSequence: integerValue(frame.source_sequence, "M4.8S.frame.source_sequence"), + counts: countsValue(frame.counts, "M4.8S.frame.counts"), + }; +} + +function candidateValue(value: unknown): M48SDetectorCandidate { + const candidate = objectValue(value, "M4.8S.candidate"); + const score = candidate.frame_253_dog_score; + if (score !== null && (typeof score !== "number" || !Number.isFinite(score))) { + throw new M48SFixedClassDetectorContractError("M4.8S.candidate.frame_253_dog_score: нарушен контракт."); + } + return { + id: enumValue(candidate.id, DETECTOR_MODES, "M4.8S.candidate.id"), + label: textValue(candidate.label, "M4.8S.candidate.label"), + providerId: textValue(candidate.provider_id, "M4.8S.candidate.provider_id"), + capacityFps: numberValue(candidate.capacity_fps, "M4.8S.candidate.capacity_fps"), + p95Ms: numberValue(candidate.p95_ms, "M4.8S.candidate.p95_ms"), + frame253DogDetected: booleanValue( + candidate.frame_253_dog_detected, + "M4.8S.candidate.frame_253_dog_detected", + ), + frame253DogScore: score, + selected: booleanValue(candidate.selected, "M4.8S.candidate.selected"), + }; +} + +function integerRecordValue(value: unknown, label: string): Readonly> { + const record = objectValue(value, label); + return Object.fromEntries( + Object.entries(record).map(([key, item]) => [key, integerValue(item, `${label}.${key}`)]), + ); +} + +function integratedWorldStateValue(value: unknown): M48SIntegratedWorldState { + const integrated = objectValue(value, "M4.8S.metrics.integrated_world_state"); + const queues = integerRecordValue( + integrated.queue_high_watermarks, + "M4.8S.integrated.queue_high_watermarks", + ); + const advisory = integerRecordValue( + integrated.advisory_family_counts, + "M4.8S.integrated.advisory_family_counts", + ); + const motion = integerRecordValue( + integrated.motion_counts, + "M4.8S.integrated.motion_counts", + ); + for (const key of ["detector", "geometry", "temporal", "rolling", "threat"] as const) { + if (!(key in queues)) { + throw new M48SFixedClassDetectorContractError(`M4.8S.integrated.queue_high_watermarks.${key}: отсутствует.`); + } + } + for (const key of ["animal", "generic-obstacle", "light-road-user", "person", "vehicle"] as const) { + if (!(key in advisory)) { + throw new M48SFixedClassDetectorContractError(`M4.8S.integrated.advisory_family_counts.${key}: отсутствует.`); + } + } + for (const key of ["moving", "stationary", "unknown"] as const) { + if (!(key in motion)) { + throw new M48SFixedClassDetectorContractError(`M4.8S.integrated.motion_counts.${key}: отсутствует.`); + } + } + return { + durationSeconds: numberValue(integrated.duration_seconds, "M4.8S.integrated.duration_seconds"), + sourceFramesAdmitted: integerValue(integrated.source_frames_admitted, "M4.8S.integrated.source_frames_admitted"), + deliveredWorldStates: integerValue(integrated.delivered_world_states, "M4.8S.integrated.delivered_world_states"), + supersededFrames: integerValue(integrated.superseded_frames, "M4.8S.integrated.superseded_frames"), + effectiveWorldStateFps: numberValue(integrated.effective_world_state_fps, "M4.8S.integrated.effective_world_state_fps"), + worldStateCompletionAgeP95Ms: numberValue(integrated.world_state_completion_age_p95_ms, "M4.8S.integrated.world_state_completion_age_p95_ms"), + worldStateCompletionAgeP99Ms: numberValue(integrated.world_state_completion_age_p99_ms, "M4.8S.integrated.world_state_completion_age_p99_ms"), + worldStateCompletionAgeMaximumMs: numberValue(integrated.world_state_completion_age_maximum_ms, "M4.8S.integrated.world_state_completion_age_maximum_ms"), + localObstacleMapOutputAgeP95Ms: numberValue(integrated.local_obstacle_map_output_age_p95_ms, "M4.8S.integrated.local_obstacle_map_output_age_p95_ms"), + queueHighWatermarks: queues as M48SIntegratedWorldState["queueHighWatermarks"], + queueCapacity: integerValue(integrated.queue_capacity, "M4.8S.integrated.queue_capacity"), + gpuUtilizationMeanPercent: numberValue(integrated.gpu_utilization_mean_percent, "M4.8S.integrated.gpu_utilization_mean_percent"), + gpuUtilizationMaximumPercent: numberValue(integrated.gpu_utilization_maximum_percent, "M4.8S.integrated.gpu_utilization_maximum_percent"), + gpuMemoryMaximumMib: numberValue(integrated.gpu_memory_maximum_mib, "M4.8S.integrated.gpu_memory_maximum_mib"), + gpuPowerMaximumW: numberValue(integrated.gpu_power_maximum_w, "M4.8S.integrated.gpu_power_maximum_w"), + gpuTemperatureMaximumC: numberValue(integrated.gpu_temperature_maximum_c, "M4.8S.integrated.gpu_temperature_maximum_c"), + uniqueComponentCount: integerValue(integrated.unique_component_count, "M4.8S.integrated.unique_component_count"), + multiFrameComponentCount: integerValue(integrated.multi_frame_component_count, "M4.8S.integrated.multi_frame_component_count"), + maximumComponentPublications: integerValue(integrated.maximum_component_publications, "M4.8S.integrated.maximum_component_publications"), + duplicateComponentIdsWithinFrame: integerValue(integrated.duplicate_component_ids_within_frame, "M4.8S.integrated.duplicate_component_ids_within_frame"), + advisoryFamilyCounts: advisory as M48SIntegratedWorldState["advisoryFamilyCounts"], + semanticHintCounts: integerRecordValue(integrated.semantic_hint_counts, "M4.8S.integrated.semantic_hint_counts"), + motionCounts: motion as M48SIntegratedWorldState["motionCounts"], + additionalInferencePasses: integerValue(integrated.additional_inference_passes, "M4.8S.integrated.additional_inference_passes"), + failures: integerValue(integrated.failures, "M4.8S.integrated.failures"), + }; +} + +function parseResult(value: unknown, resultId: string): M48SFixedClassDetectorResult { + const payload = objectValue(value, "M4.8S"); + exact( + payload.schema_version, + "missioncore.m48s-fixed-class-detector-result-view/v1", + "M4.8S.schema_version", + ); + exact(payload.result_id, resultId, "M4.8S.result_id"); + const status = enumValue(payload.status, RESULT_STATUSES, "M4.8S.status"); + const integratedGate = status === "complete-reference-graph-shadow-passed-production-not-authorized"; + exact(payload.bounded_question_accepted, true, "M4.8S.bounded_question_accepted"); + exact(payload.ground_truth, false, "M4.8S.ground_truth"); + exact(payload.access, "read-only", "M4.8S.access"); + const source = objectValue(payload.source, "M4.8S.source"); + const configuration = objectValue(payload.configuration, "M4.8S.configuration"); + const metrics = objectValue(payload.metrics, "M4.8S.metrics"); + const load = objectValue(metrics.detector_load, "M4.8S.metrics.detector_load"); + const decision = objectValue(payload.decision, "M4.8S.decision"); + const cameraRaster = arrayValue(source.camera_raster, "M4.8S.source.camera_raster"); + if (cameraRaster.length !== 2) { + throw new M48SFixedClassDetectorContractError("M4.8S.source.camera_raster: нарушен размер."); + } + exact(cameraRaster[0], 800, "M4.8S.source.camera_raster[0]"); + exact(cameraRaster[1], 600, "M4.8S.source.camera_raster[1]"); + const displayModes = arrayValue( + configuration.display_modes, + "M4.8S.configuration.display_modes", + ).map((mode, index) => enumValue(mode, MODES, `M4.8S.configuration.display_modes[${index}]`)); + if (displayModes.join(",") !== MODES.join(",")) { + throw new M48SFixedClassDetectorContractError("M4.8S.configuration.display_modes: изменён контракт."); + } + exact(source.source_session_id, "RAVNOVES00", "M4.8S.source.source_session_id"); + exact(source.camera_source_id, "sensor.camera.right", "M4.8S.source.camera_source_id"); + exact(configuration.comparison_threshold, 0.5, "M4.8S.configuration.comparison_threshold"); + exact(configuration.single_inference_per_frame, true, "M4.8S.configuration.single_inference_per_frame"); + exact(configuration.geometry_owns_static_occupancy, true, "M4.8S.configuration.geometry_owns_static_occupancy"); + exact(configuration.unknown_stationary_response, "route-around", "M4.8S.configuration.unknown_stationary_response"); + exact(configuration.unknown_moving_response, "conservative-risk", "M4.8S.configuration.unknown_moving_response"); + exact(decision.selected_candidate, "rf-detr", "M4.8S.decision.selected_candidate"); + exact(decision.ready_for_reference_graph_shadow, true, "M4.8S.decision.ready_for_reference_graph_shadow"); + exact(decision.integrated_world_state_gate_evaluated, integratedGate, "M4.8S.decision.integrated_world_state_gate_evaluated"); + if (integratedGate) { + exact(decision.integrated_world_state_gate_passed, true, "M4.8S.decision.integrated_world_state_gate_passed"); + exact(decision.detector_replacement_authorized, false, "M4.8S.decision.detector_replacement_authorized"); + } + exact(decision.production_accepted, false, "M4.8S.decision.production_accepted"); + const frames = arrayValue(payload.frames, "M4.8S.frames").map(descriptorValue); + const evidenceFrameCount = integerValue(source.evidence_frame_count, "M4.8S.source.evidence_frame_count"); + if (frames.length !== evidenceFrameCount || new Set(frames.map((frame) => frame.frameId)).size !== frames.length) { + throw new M48SFixedClassDetectorContractError("M4.8S.frames: каталог изменён."); + } + return { + resultId, + createdAtUtc: textValue(payload.created_at_utc, "M4.8S.created_at_utc"), + status, + boundedQuestionAccepted: true, + groundTruth: false, + source: { + sourceSessionId: "RAVNOVES00", + cameraSourceId: "sensor.camera.right", + cameraRaster: [800, 600], + evidenceFrameCount, + }, + configuration: { + comparisonThreshold: 0.5, + displayModes, + singleInferencePerFrame: true, + geometryOwnsStaticOccupancy: true, + unknownStationaryResponse: "route-around", + unknownMovingResponse: "conservative-risk", + }, + method: methodValue(payload.method), + metrics: { + candidates: arrayValue(metrics.candidates, "M4.8S.metrics.candidates").map(candidateValue), + detectorLoad: { + durationSeconds: numberValue(load.duration_seconds, "M4.8S.load.duration_seconds"), + sourceFramesConsumed: integerValue(load.source_frames_consumed, "M4.8S.load.source_frames_consumed"), + sourceFrameReplacements: integerValue(load.source_frame_replacements, "M4.8S.load.source_frame_replacements"), + effectiveConsumedFps: numberValue(load.effective_consumed_fps, "M4.8S.load.effective_consumed_fps"), + endToEndP95Ms: numberValue(load.end_to_end_p95_ms, "M4.8S.load.end_to_end_p95_ms"), + completionAgeP95Ms: numberValue(load.completion_age_p95_ms, "M4.8S.load.completion_age_p95_ms"), + gpuUtilizationMeanPercent: numberValue(load.gpu_utilization_mean_percent, "M4.8S.load.gpu_utilization_mean_percent"), + gpuUtilizationMaximumPercent: numberValue(load.gpu_utilization_maximum_percent, "M4.8S.load.gpu_utilization_maximum_percent"), + gpuMemoryMaximumMib: numberValue(load.gpu_memory_maximum_mib, "M4.8S.load.gpu_memory_maximum_mib"), + queueMaximumDepth: integerValue(load.queue_maximum_depth, "M4.8S.load.queue_maximum_depth"), + queueCapacity: integerValue(load.queue_capacity, "M4.8S.load.queue_capacity"), + failures: integerValue(load.failures, "M4.8S.load.failures"), + }, + integratedWorldState: integratedGate + ? integratedWorldStateValue(metrics.integrated_world_state) + : null, + }, + decision: { + selectedCandidate: "rf-detr", + readyForReferenceGraphShadow: true, + integratedWorldStateGateEvaluated: integratedGate, + integratedWorldStateGatePassed: integratedGate, + detectorReplacementAuthorized: false, + productionAccepted: false, + }, + limitations: arrayValue(payload.limitations, "M4.8S.limitations").map((item, index) => textValue(item, `M4.8S.limitations[${index}]`)), + authority: authorityValue(payload.authority, "M4.8S.authority"), + frames, + }; +} + +function detectionValue(value: unknown, label: string): M48SDetection { + const detection = objectValue(value, label); + const bbox = arrayValue(detection.bbox_xyxy, `${label}.bbox_xyxy`).map((item, index) => numberValue(item, `${label}.bbox_xyxy[${index}]`)); + if ( + bbox.length !== 4 + || bbox[0]! < 0 + || bbox[1]! < 0 + || bbox[2]! > 800 + || bbox[3]! > 600 + || bbox[0]! >= bbox[2]! + || bbox[1]! >= bbox[3]! + ) { + throw new M48SFixedClassDetectorContractError(`${label}.bbox_xyxy: рамка недопустима.`); + } + const score = numberValue(detection.score, `${label}.score`); + if (score < 0.5 || score > 1) { + throw new M48SFixedClassDetectorContractError(`${label}.score: нарушен порог.`); + } + return { + label: textValue(detection.label, `${label}.label`), + score, + bboxXyxy: bbox as unknown as readonly [number, number, number, number], + }; +} + +function parseFrame(value: unknown, resultId: string, frameId: string): M48SDetectorFrame { + const payload = objectValue(value, "M4.8S frame"); + exact(payload.schema_version, "missioncore.m48s-fixed-class-detector-frame/v1", "M4.8S frame.schema_version"); + exact(payload.result_id, resultId, "M4.8S frame.result_id"); + exact(payload.frame_id, frameId, "M4.8S frame.frame_id"); + exact(payload.comparison_threshold, 0.5, "M4.8S frame.comparison_threshold"); + exact(payload.ground_truth_available, false, "M4.8S frame.ground_truth_available"); + exact(payload.access, "read-only", "M4.8S frame.access"); + const camera = objectValue(payload.camera, "M4.8S frame.camera"); + exact(camera.media_type, "image/jpeg", "M4.8S frame.camera.media_type"); + exact(camera.width, 800, "M4.8S frame.camera.width"); + exact(camera.height, 600, "M4.8S frame.camera.height"); + exact(camera.exact_source_frame, true, "M4.8S frame.camera.exact_source_frame"); + const detections = objectValue(payload.detections, "M4.8S frame.detections"); + const parseModel = (model: M48SDetectorModel): readonly M48SDetection[] => arrayValue( + detections[model], + `M4.8S frame.detections.${model}`, + ).map((item, index) => detectionValue(item, `M4.8S frame.detections.${model}[${index}]`)); + return { + resultId, + frameId, + sourceSequence: integerValue(payload.source_sequence, "M4.8S frame.source_sequence"), + cameraUrl: `/api/v1/laboratory/m48s/fixed-class-detector/${encodeURIComponent(resultId)}/frames/${encodeURIComponent(frameId)}/camera`, + imageWidth: 800, + imageHeight: 600, + comparisonThreshold: 0.5, + detections: { + yolox: parseModel("yolox"), + dfine: parseModel("dfine"), + "rf-detr": parseModel("rf-detr"), + }, + groundTruthAvailable: false, + authority: authorityValue(payload.authority, "M4.8S frame.authority"), + }; +} + +export async function fetchM48SFixedClassDetectorResult( + resultId: string, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(resultId)) { + throw new M48SFixedClassDetectorContractError("M4.8S result identity недопустима."); + } + const response = await fetcher( + `/api/v1/laboratory/m48s/fixed-class-detector/${encodeURIComponent(resultId)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new M48SFixedClassDetectorContractError(`M4.8S недоступен: HTTP ${response.status}.`); + } + return parseResult(await response.json(), resultId); +} + +export async function fetchM48SFixedClassDetectorFrame( + resultId: string, + frameId: string, + { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!RESULT_ID.test(resultId) || !FRAME_ID.test(frameId)) { + throw new M48SFixedClassDetectorContractError("M4.8S frame identity недопустима."); + } + const response = await fetcher( + `/api/v1/laboratory/m48s/fixed-class-detector/${encodeURIComponent(resultId)}/frames/${encodeURIComponent(frameId)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new M48SFixedClassDetectorContractError(`M4.8S frame недоступен: HTTP ${response.status}.`); + } + return parseFrame(await response.json(), resultId, frameId); +} diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index 2a61148..489ed93 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -133,6 +133,8 @@ export interface M4ThreatTimelineFrame { sessionSeconds: number; sourceAvailable: boolean; spatialAvailable: boolean; + worldStateAvailable: boolean; + terminalOutcome: "delivered" | "superseded"; bodyFrame: { originMapXyzM: M4Point3; basisMapFromBody: M4Matrix3; @@ -141,6 +143,11 @@ export interface M4ThreatTimelineFrame { pointCloudSourceCount: number; pointCloudSampleCount: number; pointCloudLayer: "current-increment"; + cameraProjectedPointsXyd: readonly (readonly [number, number, number])[]; + cameraProjectedSourceCount: number; + cameraProjectedPointCount: number; + cameraProjectedSampleCount: number; + cameraProjection: "factory-kb4-exact" | null; rollingMapComponentCount: number; metricObstacles: readonly M4ThreatMetricVisual[]; cameraProposals: readonly M4ThreatCameraProposal[]; @@ -163,6 +170,11 @@ export interface M4ThreatTimeline { pointSampleLimit: number; maximumSourcePointsPerFrame: number; pointDelivery: "exact-current-increment"; + cameraPointDelivery: "factory-kb4-projected-current-increment" | null; + cameraPointSampleLimit: number; + worldStateDelivery: "source-paced-latest-wins" | null; + worldStateFrameCount: number; + supersededFrameCount: number; sourceRepresentationId: "registered-map-increment-v1"; localSurfaceVisualization: { derivation: "bounded-registered-increment-accumulation"; @@ -186,6 +198,7 @@ export interface M4ThreatTimelineChunk { } type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; +export const M4_THREAT_TIMELINE_ENDPOINT_ROOT = "/api/v1/laboratory/m4-threat/results"; class M4ThreatContractError extends Error {} const object = (value: unknown, label: string): Record => { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -553,10 +566,18 @@ export async function fetchM4ThreatVisual( export async function fetchM4ThreatTimeline( result: string, - { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, + { + fetcher = fetch, + signal, + endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + endpointRoot?: string; + } = {}, ): Promise { const response = await fetcher( - `/api/v1/laboratory/m4-threat/results/${result}/timeline`, + `${endpointRoot}/${result}/timeline`, { headers: { Accept: "application/json" }, signal }, ); if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline: HTTP ${response.status}.`); @@ -626,6 +647,29 @@ export async function fetchM4ThreatTimeline( "exact-current-increment", "M4.6 point delivery", ), + cameraPointDelivery: payload.camera_point_delivery === undefined + ? null + : exact( + payload.camera_point_delivery, + "factory-kb4-projected-current-increment", + "M4.6 camera point delivery", + ), + cameraPointSampleLimit: payload.camera_point_sample_limit === undefined + ? 0 + : integer(payload.camera_point_sample_limit, "M4.6 camera point limit"), + worldStateDelivery: payload.world_state_delivery === undefined + ? null + : exact( + payload.world_state_delivery, + "source-paced-latest-wins", + "M4.6 world-state delivery", + ), + worldStateFrameCount: payload.world_state_frame_count === undefined + ? frameCount + : integer(payload.world_state_frame_count, "M4.6 world-state frames"), + supersededFrameCount: payload.superseded_frame_count === undefined + ? 0 + : integer(payload.superseded_frame_count, "M4.6 superseded frames"), sourceRepresentationId: "registered-map-increment-v1", localSurfaceVisualization: { derivation: exact( @@ -668,14 +712,22 @@ export async function fetchM4ThreatTimelineChunk( result: string, startSequence: number, frameCount: number, - { fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, + { + fetcher = fetch, + signal, + endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + endpointRoot?: string; + } = {}, ): Promise { const params = new URLSearchParams({ start: String(startSequence), count: String(frameCount), }); const response = await fetcher( - `/api/v1/laboratory/m4-threat/results/${result}/timeline/chunk?${params}`, + `${endpointRoot}/${result}/timeline/chunk?${params}`, { headers: { Accept: "application/json" }, signal }, ); if (!response.ok) throw new M4ThreatContractError(`M4.6 timeline chunk: HTTP ${response.status}.`); @@ -692,7 +744,7 @@ export async function fetchM4ThreatTimelineChunk( throw new M4ThreatContractError("M4.6 timeline chunk start: нарушен контракт."); } const frames = array(payload.frames, "M4.6 timeline frames").map((raw, offset) => - parseTimelineFrame(raw, result, parsedStart + offset)); + parseTimelineFrame(raw, result, parsedStart + offset, endpointRoot)); const parsedCount = integer(payload.frame_count, "M4.6 timeline chunk count"); if (parsedCount !== frames.length || parsedCount > frameCount) { throw new M4ThreatContractError("M4.6 timeline chunk count: нарушен контракт."); @@ -712,6 +764,7 @@ function parseTimelineFrame( value: unknown, result: string, expectedSequence: number, + endpointRoot: string, ): M4ThreatTimelineFrame { const item = object(value, "M4.6 timeline frame"); exact( @@ -742,7 +795,7 @@ function parseTimelineFrame( throw new M4ThreatContractError("M4.6 timeline body basis: нарушен размер."); } const cameraUrl = text(item.camera_url, "M4.6 timeline camera URL"); - if (!cameraUrl.includes(`/results/${result}/timeline/frames/${sequence}/camera`)) { + if (!cameraUrl.includes(`${endpointRoot}/${result}/timeline/frames/${sequence}/camera`)) { throw new M4ThreatContractError("M4.6 timeline camera URL: нарушена идентичность."); } return { @@ -752,6 +805,16 @@ function parseTimelineFrame( sessionSeconds: number(item.session_seconds, "M4.6 timeline time"), sourceAvailable: typeof item.source_available === "boolean" && item.source_available, spatialAvailable, + worldStateAvailable: item.world_state_available === undefined + ? true + : typeof item.world_state_available === "boolean" && item.world_state_available, + terminalOutcome: item.terminal_outcome === undefined + ? "delivered" + : exact( + item.terminal_outcome, + item.world_state_available === false ? "superseded" : "delivered", + "M4.6 terminal outcome", + ), bodyFrame: bodyFrame === null || basis === null ? null : { @@ -771,6 +834,23 @@ function parseTimelineFrame( "current-increment", "M4.6 timeline point layer", ), + cameraProjectedPointsXyd: item.camera_projected_points_xyd === undefined + ? [] + : array(item.camera_projected_points_xyd, "M4.6 camera points").map( + (point) => vector(point, 3, "M4.6 camera point") as [number, number, number], + ), + cameraProjectedSourceCount: item.camera_projected_source_count === undefined + ? 0 + : integer(item.camera_projected_source_count, "M4.6 camera point source count"), + cameraProjectedPointCount: item.camera_projected_point_count === undefined + ? 0 + : integer(item.camera_projected_point_count, "M4.6 projected point count"), + cameraProjectedSampleCount: item.camera_projected_sample_count === undefined + ? 0 + : integer(item.camera_projected_sample_count, "M4.6 projected point sample count"), + cameraProjection: item.camera_projection === undefined + ? null + : exact(item.camera_projection, "factory-kb4-exact", "M4.6 camera projection"), rollingMapComponentCount: integer( item.rolling_map_component_count, "M4.6 rolling components", diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index 70f40bc..ef966f4 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -44,6 +44,7 @@ import { M4ReplayThreatResultView } from "./M4ReplayThreatResult"; import { M47ReferenceGraphResultView } from "./M47ReferenceGraphResult"; import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult"; import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult"; +import { M48SFixedClassDetectorResultView } from "./M48SFixedClassDetectorResult"; export { isAdvancedLaboratoryWorkId }; export type { AdvancedLaboratoryWorkId }; @@ -92,6 +93,9 @@ export function AdvancedLaboratoryResult({ if (workId === "m48-small-static-passage-regression" && results.m48SmallStatic) { return ; } + if (workId === "m48s-fixed-class-detector" && results.m48s) { + return ; + } if (workId === "m47-reference-graph-shadow" && results.m47Graph) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorResult.tsx b/apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorResult.tsx new file mode 100644 index 0000000..3618d78 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorResult.tsx @@ -0,0 +1,103 @@ +import { + LaboratoryEvidence, + LaboratoryResultSummary, + LaboratorySummary, + LaboratoryWorkTemplate, +} from "../../components/laboratory/LaboratoryPresentation"; +import type { M48SFixedClassDetectorResult } from "../../core/laboratory/m48sFixedClassDetector"; +import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual"; + +function decimal(value: number, digits = 1): string { + return value.toLocaleString("ru-RU", { maximumFractionDigits: digits }); +} + +export function M48SFixedClassDetectorResultView({ + rigLabel, + result, +}: { + rigLabel: string; + result: M48SFixedClassDetectorResult; +}) { + const selected = result.metrics.candidates.find((candidate) => candidate.selected); + const load = result.metrics.detectorLoad; + const integrated = result.metrics.integratedWorldState; + const status = integrated + ? "Полный RF-DETR reference graph выдержал realtime shadow" + : "RF-DETR-L выдержал detector-only realtime shadow"; + return ( + ({ + ...component, + identitySha256: component.identitySha256, + })), + }} + /> + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorVisual.tsx b/apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorVisual.tsx new file mode 100644 index 0000000..8ae706b --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48SFixedClassDetectorVisual.tsx @@ -0,0 +1,168 @@ +import { useEffect, useMemo, useState } from "react"; +import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react"; + +import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; +import { + RecordedEvidenceBoxOverlay, + type RecordedEvidenceBox, + type RecordedEvidenceBoxTone, +} from "../../components/laboratory/RecordedEvidenceBoxOverlay"; +import { + fetchM48SFixedClassDetectorFrame, + type M48SDetectorFrame, + type M48SDetectorMode, + type M48SFixedClassDetectorResult, +} from "../../core/laboratory/m48sFixedClassDetector"; + +const MODES = [ + { value: "source", label: "SOURCE" }, + { value: "yolox", label: "YOLOX" }, + { value: "dfine", label: "D-FINE" }, + { value: "rf-detr", label: "RF-DETR" }, +] as const; + +const ANIMAL_LABELS = new Set([ + "bird", + "cat", + "dog", + "horse", + "sheep", + "cow", + "elephant", + "bear", + "zebra", + "giraffe", +]); +const VULNERABLE_ROAD_USERS = new Set(["person", "bicycle", "motorcycle", "skateboard"]); + +function message(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "M4.8S visual evidence недоступно."; +} + +function toneForLabel(label: string): RecordedEvidenceBoxTone { + if (ANIMAL_LABELS.has(label)) return "danger"; + if (VULNERABLE_ROAD_USERS.has(label)) return "warning"; + return "accent"; +} + +function DetectorScene({ frame, mode }: { frame: M48SDetectorFrame; mode: M48SDetectorMode }) { + const boxes = useMemo(() => { + if (mode === "source") return []; + return frame.detections[mode].map((detection) => ({ + boxXyxy: detection.bboxXyxy, + label: `${detection.label} · ${detection.score.toFixed(2)}`, + tone: toneForLabel(detection.label), + })); + }, [frame, mode]); + + return ( +
+ + +
+ ); +} + +export function M48SFixedClassDetectorVisual({ + result, +}: { + result: M48SFixedClassDetectorResult; +}) { + const preferredIndex = Math.max( + 0, + result.frames.findIndex((frame) => frame.frameId === "000253"), + ); + const [index, setIndex] = useState(preferredIndex); + const [frame, setFrame] = useState(null); + const [mode, setMode] = useState("rf-detr"); + const [expanded, setExpanded] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const selected = result.frames[index] ?? null; + + useEffect(() => { + if (!selected) { + setFrame(null); + setLoading(false); + return; + } + const controller = new AbortController(); + setLoading(true); + setError(null); + void fetchM48SFixedClassDetectorFrame(result.resultId, selected.frameId, { + signal: controller.signal, + }) + .then((next) => !controller.signal.aborted && setFrame(next)) + .catch((caught: unknown) => !controller.signal.aborted && setError(message(caught))) + .finally(() => !controller.signal.aborted && setLoading(false)); + return () => controller.abort(); + }, [result.resultId, selected]); + + const count = frame && mode !== "source" ? frame.detections[mode].length : 0; + return ( + + setIndex((current) => ( + current - 1 + result.frames.length + ) % result.frames.length)} + > + + + setIndex((current) => (current + 1) % result.frames.length)} + > + + + + )} + overlay={selected ? ( +
+ SHADOW ONLY + RAVNOVES00 · frame {selected.frameId} · {mode.toUpperCase()} + + {count} risk detections · threshold 0.50 · independent ground truth отсутствует + +
+ ) : null} + > + {loading ? ( +
+
+ ) : error ? ( +
+ + {error} +
+ ) : frame ? ( + + ) : ( +
+ + Каталог кадров M4.8S пуст. +
+ )} +
+ ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index 0f47d33..c160e21 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -17,6 +17,7 @@ import { } from "../../components/laboratory/LaboratoryMetricEvidenceScene"; import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; import { RecordedEvidenceImageScene } from "../../components/laboratory/RecordedEvidenceImageScene"; +import type { RecordedEvidencePointCloudOverlayData } from "../../components/laboratory/RecordedEvidencePointCloudOverlay"; import type { RecordedEvidenceSemanticClass, RecordedEvidenceSemanticOverlay, @@ -58,10 +59,14 @@ function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox[ } function proposalLabel(proposal: M4ThreatCameraProposal): string { - const decision = proposal.threatDecision ?? "unknown"; - if (proposal.rangeM === null) return decision; + const decision = proposal.threatDecision + ?? (proposal.occupiedSupport ? "geometry-supported" : "camera-only"); + const semantic = proposal.semanticHint ?? "object"; + if (proposal.rangeM === null) { + return `${semantic} · ${proposal.objectness.toFixed(2)} · ${decision}`; + } const range = `${proposal.rangeM.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`; - return `${range} · ${decision}`; + return `${semantic} · ${proposal.objectness.toFixed(2)} · ${range} · ${decision}`; } function boxes(proposals: readonly M4ThreatCameraProposal[]): readonly RecordedEvidenceBox[] { @@ -104,10 +109,14 @@ export function M4ReplayThreatVisual({ resultId, semantic, reviewAnchors = EMPTY_REVIEW_ANCHORS, + timelineEndpointRoot, + evidenceLabel = "M4.6", }: { resultId: string; semantic?: M4ReplayThreatSemanticLayer; reviewAnchors?: readonly M4ReplayThreatReviewAnchor[]; + timelineEndpointRoot?: string; + evidenceLabel?: string; }) { const [mediaMode, setMediaMode] = useState("video"); const [spatialMode, setSpatialMode] = useState(null); @@ -116,6 +125,7 @@ export function M4ReplayThreatVisual({ const [showRollingMap, setShowRollingMap] = useState(true); const [showMediaSemantic, setShowMediaSemantic] = useState(true); const [showSpatialSemantic, setShowSpatialSemantic] = useState(true); + const [showMediaPoints, setShowMediaPoints] = useState(false); const [splitPrimarySize, setSplitPrimarySize] = useState(50); const [splitOrientation, setSplitOrientation] = useState(() => ( typeof window !== "undefined" && window.matchMedia("(max-width: 900px)").matches @@ -125,7 +135,7 @@ export function M4ReplayThreatVisual({ const [expanded, setExpanded] = useState(false); const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0); const metricSceneRef = useRef(null); - const metadata = useM4ThreatTimelineMetadata(resultId); + const metadata = useM4ThreatTimelineMetadata(resultId, timelineEndpointRoot); const playbackRange = useMemo(() => metadata.timeline ? ({ startSeconds: metadata.timeline.timelineStartSeconds, endSeconds: metadata.timeline.timelineEndSeconds, @@ -139,6 +149,7 @@ export function M4ReplayThreatVisual({ resultId, timeline: metadata.timeline, currentSeconds: playbackController.playback.currentSeconds, + endpointRoot: timelineEndpointRoot, }); const [videoSource, setVideoSource] = useState(null); const [videoLoading, setVideoLoading] = useState(false); @@ -362,6 +373,16 @@ export function M4ReplayThreatVisual({ ariaLabel: `E47 semantic mask frame ${frame.sequence + 1}`, } : undefined; + const pointCloudOverlay: RecordedEvidencePointCloudOverlayData | undefined = + showMediaPoints && frame?.cameraProjection === "factory-kb4-exact" + ? { + pointsXyd: frame.cameraProjectedPointsXyd, + sourcePointCount: frame.cameraProjectedSourceCount, + projectedPointCount: frame.cameraProjectedPointCount, + projection: "factory-kb4-exact", + ariaLabel: `${evidenceLabel} LiDAR projection: ${frame.cameraProjectedSampleCount} points`, + } + : undefined; const handleMediaModeChange = (next: M4ThreatMediaSelection) => { if (next === "none") return; @@ -408,21 +429,35 @@ export function M4ReplayThreatVisual({ ); - const mediaLayerControls = semantic ? ( + const mediaLayerControls = semantic || metadata.timeline?.cameraPointDelivery ? (
- + {semantic ? ( + + ) : null} + {metadata.timeline?.cameraPointDelivery ? ( + + ) : null}
) : null; @@ -570,6 +605,12 @@ export function M4ReplayThreatVisual({ {spatialFrame ? `${spatialFrame.pointCloudSampleCount}/${spatialFrame.pointCloudSourceCount} exact · ${localSurface.pointsBodyXyzM.length} local SLAM / ${localSurface.sourceFrameCount} frames` : "квалифицированный spatial frame ещё не получен"} + {frame.worldStateAvailable + ? " · world-state delivered" + : ` · world-state gap (${frame.terminalOutcome})`} + {pointCloudOverlay + ? ` · camera points ${frame.cameraProjectedSampleCount}/${frame.cameraProjectedPointCount}` + : ""} {semantic && spatialSemanticFrame ? ` · semantic L ${spatialSemanticFrame.counts.labeled} · A ${spatialSemanticFrame.counts.ambiguous} · U ${spatialSemanticFrame.counts.unprojected} · Ø ${spatialSemanticFrame.counts.absent}` : semantic ? " · semantic buffer" : ""} @@ -595,7 +636,7 @@ export function M4ReplayThreatVisual({ content = (
); } else { @@ -628,7 +669,8 @@ export function M4ReplayThreatVisual({ imageHeight={timeline.imageHeight} boxes={activeBoxes} semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined} - ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} + pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined} + ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} interactive={false} segmentSequence={ timelineFrame.activeSequence === null @@ -655,7 +697,8 @@ export function M4ReplayThreatVisual({ imageHeight={timeline.imageHeight} boxes={activeBoxes} semanticOverlay={semanticOverlay} - ariaLabel={`M4.6 exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`} + pointCloudOverlay={pointCloudOverlay} + ariaLabel={`${evidenceLabel} exact camera frame ${frame.sequence}: ${activeBoxes.length} proposals`} /> ) : null} @@ -689,7 +732,7 @@ export function M4ReplayThreatVisual({ corridor={timeline.corridor} occupiedVoxelSizeM={timeline.occupiedVoxelSizeM} mode={spatialMode} - label="M4.6 exact current increment, bounded local SLAM surface and rolling occupancy" + label={`${evidenceLabel} exact current increment, bounded local SLAM surface and rolling occupancy`} showCurrentIncrement={showCurrentIncrement} showLocalSurface={showLocalSurface} showRollingMap={showRollingMap} @@ -791,7 +834,7 @@ export function M4ReplayThreatVisual({ `${rig(rigLabel)} RIGHT · RAVNOVES00 perception gate`, + experimentId: "m48s-fixed-class-risk-detector", + experimentName: "RAVNOVES00 fixed-class risk detector", + variantName: "M4.8S · RF-DETR-L TensorRT/Triton shadow", + }, "m47-reference-graph-shadow": { profileId: "rig-dual-evidence-virtual-corridor-v1", profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`, diff --git a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts index fa99040..6191021 100644 --- a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts +++ b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts @@ -21,6 +21,7 @@ function mergeResults( m47Graph: next.m47Graph ?? current.m47Graph, m48: next.m48 ?? current.m48, m48SmallStatic: next.m48SmallStatic ?? current.m48SmallStatic, + m48s: next.m48s ?? current.m48s, m4Threat: next.m4Threat ?? current.m4Threat, l3: next.l3 ?? current.l3, l31: next.l31 ?? current.l31, diff --git a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts index 5a1ea07..3cdfac2 100644 --- a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts @@ -41,7 +41,7 @@ export function cancelM4ThreatChunkRequestsOutsideWindow(null); const [error, setError] = useState(null); @@ -49,7 +49,7 @@ export function useM4ThreatTimelineMetadata(resultId: string) { const controller = new AbortController(); setTimeline(null); setError(null); - void fetchM4ThreatTimeline(resultId, { signal: controller.signal }) + void fetchM4ThreatTimeline(resultId, { signal: controller.signal, endpointRoot }) .then((next) => { if (!controller.signal.aborted) setTimeline(next); }) @@ -59,7 +59,7 @@ export function useM4ThreatTimelineMetadata(resultId: string) { } }); return () => controller.abort(); - }, [resultId]); + }, [endpointRoot, resultId]); return { timeline, loading: !timeline && !error, error }; } @@ -68,10 +68,12 @@ export function useM4ThreatTimelineFrame({ resultId, timeline, currentSeconds, + endpointRoot, }: { resultId: string; timeline: M4ThreatTimeline | null; currentSeconds: number; + endpointRoot?: string; }) { const [chunks, setChunks] = useState>( () => new Map(), @@ -124,6 +126,7 @@ export function useM4ThreatTimelineFrame({ inFlight.current.set(start, controller); void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, { signal: controller.signal, + endpointRoot, }) .then((chunk) => { if (controller.signal.aborted) return; @@ -151,7 +154,7 @@ export function useM4ThreatTimelineFrame({ if (inFlight.current.get(start) === controller) inFlight.current.delete(start); }); } - }, [activeChunkStart, chunkSize, resultId, timeline]); + }, [activeChunkStart, chunkSize, endpointRoot, resultId, timeline]); const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { if (activeSequence === null || activeChunkStart === null) return null; diff --git a/apps/control-station/test/m48sFixedClassDetector.test.mjs b/apps/control-station/test/m48sFixedClassDetector.test.mjs new file mode 100644 index 0000000..712f31c --- /dev/null +++ b/apps/control-station/test/m48sFixedClassDetector.test.mjs @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { createServer } from "vite"; + +let server; +let fetchM48SFixedClassDetectorResult; +let fetchM48SFixedClassDetectorFrame; + +const resultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`; +const authority = { + actuation_allowed: false, + candidate_accepted: false, + commands_enabled: false, + ground_truth: false, + navigation_or_safety_accepted: false, +}; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ + fetchM48SFixedClassDetectorResult, + fetchM48SFixedClassDetectorFrame, + } = await server.ssrLoadModule("/src/core/laboratory/m48sFixedClassDetector.ts")); +}); + +after(async () => server?.close()); + +function response(value) { + return { ok: true, status: 200, json: async () => value }; +} + +function resultPayload() { + const candidate = (id, overrides = {}) => ({ + id, + label: id, + provider_id: `${id}/v1`, + capacity_fps: 40, + p95_ms: 35, + frame_253_dog_detected: false, + frame_253_dog_score: null, + selected: false, + ...overrides, + }); + return { + schema_version: "missioncore.m48s-fixed-class-detector-result-view/v1", + result_id: resultId, + created_at_utc: "2026-08-25T11:06:28Z", + status: "complete-reference-graph-shadow-passed-production-not-authorized", + bounded_question_accepted: true, + ground_truth: false, + source: { + source_session_id: "RAVNOVES00", + camera_source_id: "sensor.camera.right", + camera_raster: [800, 600], + evidence_frame_count: 1, + }, + configuration: { + comparison_threshold: 0.5, + display_modes: ["source", "yolox", "dfine", "rf-detr"], + single_inference_per_frame: true, + geometry_owns_static_occupancy: true, + unknown_stationary_response: "route-around", + unknown_moving_response: "conservative-risk", + }, + method: { + schema_version: "missioncore.laboratory-method/v1", + completeness: "complete", + execution_class: "ai-inference", + pipeline_id: "raw-kb4-fixed-class-risk-detector-tournament/v1", + components: [{ + kind: "model", + name: "RF-DETR-L COCO", + version: "trt11-fp16", + role: "selected fixed-class risk detector", + identity_sha256: "9".repeat(64), + }], + }, + metrics: { + candidates: [ + candidate("yolox"), + candidate("dfine"), + candidate("rf-detr", { + label: "RF-DETR-L", + capacity_fps: 42.496232, + frame_253_dog_detected: true, + frame_253_dog_score: 0.741674, + selected: true, + }), + ], + detector_load: { + duration_seconds: 1800.019643, + source_frames_consumed: 18008, + source_frame_replacements: 0, + effective_consumed_fps: 10.004444, + end_to_end_p95_ms: 32.41534, + completion_age_p95_ms: 40.620542, + gpu_utilization_mean_percent: 51.407556, + gpu_utilization_maximum_percent: 65, + gpu_memory_maximum_mib: 9556, + queue_maximum_depth: 1, + queue_capacity: 2, + failures: 0, + }, + integrated_world_state: { + duration_seconds: 458.900859, + source_frames_admitted: 4489, + delivered_world_states: 4481, + superseded_frames: 8, + effective_world_state_fps: 9.764636, + world_state_completion_age_p95_ms: 74.733648, + world_state_completion_age_p99_ms: 102.62048, + world_state_completion_age_maximum_ms: 669.142137, + local_obstacle_map_output_age_p95_ms: 70.635141, + queue_high_watermarks: { detector: 2, geometry: 2, temporal: 2, rolling: 2, threat: 2 }, + queue_capacity: 2, + gpu_utilization_mean_percent: 50.903371, + gpu_utilization_maximum_percent: 60, + gpu_memory_maximum_mib: 9576, + gpu_power_maximum_w: 153.51, + gpu_temperature_maximum_c: 39, + unique_component_count: 44979, + multi_frame_component_count: 15887, + maximum_component_publications: 219, + duplicate_component_ids_within_frame: 0, + advisory_family_counts: { + animal: 63, + "generic-obstacle": 123919, + "light-road-user": 329, + person: 5600, + vehicle: 55818, + }, + semantic_hint_counts: { dog: 60, person: 5600, "geometry-only": 123919 }, + motion_counts: { moving: 12252, stationary: 1433, unknown: 172044 }, + additional_inference_passes: 0, + failures: 0, + }, + }, + decision: { + selected_candidate: "rf-detr", + ready_for_reference_graph_shadow: true, + integrated_world_state_gate_evaluated: true, + integrated_world_state_gate_passed: true, + detector_replacement_authorized: false, + production_accepted: false, + }, + limitations: ["No independent semantic ground truth."], + authority, + frames: [{ + frame_id: "000253", + source_sequence: 253, + counts: { yolox: 4, dfine: 7, "rf-detr": 8 }, + }], + access: "read-only", + }; +} + +test("M4.8S result exposes complete graph load without production authority", async () => { + const result = await fetchM48SFixedClassDetectorResult(resultId, { + fetcher: async (url) => { + assert.equal( + url, + `/api/v1/laboratory/m48s/fixed-class-detector/${resultId}`, + ); + return response(resultPayload()); + }, + }); + + assert.equal(result.metrics.detectorLoad.sourceFramesConsumed, 18008); + assert.equal(result.metrics.detectorLoad.completionAgeP95Ms, 40.620542); + assert.equal(result.metrics.candidates[2].frame253DogScore, 0.741674); + assert.equal(result.metrics.integratedWorldState.deliveredWorldStates, 4481); + assert.equal(result.metrics.integratedWorldState.worldStateCompletionAgeP95Ms, 74.733648); + assert.equal(result.metrics.integratedWorldState.additionalInferencePasses, 0); + assert.equal(result.decision.integratedWorldStateGatePassed, true); + assert.equal(result.decision.productionAccepted, false); + assert.equal(result.authority.navigationOrSafetyAccepted, false); +}); + +test("M4.8S frame binds exact camera endpoint and risk-only boxes", async () => { + const frame = await fetchM48SFixedClassDetectorFrame(resultId, "000253", { + fetcher: async () => response({ + schema_version: "missioncore.m48s-fixed-class-detector-frame/v1", + result_id: resultId, + frame_id: "000253", + source_sequence: 253, + camera: { + media_type: "image/jpeg", + width: 800, + height: 600, + exact_source_frame: true, + }, + comparison_threshold: 0.5, + detections: { + yolox: [], + dfine: [{ label: "skateboard", score: 0.782227, bbox_xyxy: [236, 334, 274, 372] }], + "rf-detr": [{ label: "dog", score: 0.741674, bbox_xyxy: [235, 336, 273, 376] }], + }, + ground_truth_available: false, + authority, + access: "read-only", + }), + }); + + assert.equal(frame.detections["rf-detr"][0].label, "dog"); + assert.equal( + frame.cameraUrl, + `/api/v1/laboratory/m48s/fixed-class-detector/${resultId}/frames/000253/camera`, + ); + assert.equal(frame.groundTruthAvailable, false); +}); + +test("M4.8S adapter rejects any navigation authority escalation", async () => { + await assert.rejects( + fetchM48SFixedClassDetectorResult(resultId, { + fetcher: async () => response({ + ...resultPayload(), + authority: { ...authority, navigation_or_safety_accepted: true }, + }), + }), + /navigation_or_safety_accepted/, + ); +}); diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 094b560..71f0615 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -326,6 +326,100 @@ test("M4.6 timeline keeps only a compact index and decodes bounded spatial chunk assert.equal(selectM4ThreatTimelineFrame(chunk.frames, 35.50).sequence, 1); }); +test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => { + const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`; + const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector"; + const frameTimesNs = Array.from( + { length: 4489 }, + (_, index) => 35_421_857_292 + index * 100_000_000, + ); + let requested = ""; + const timeline = await fetchM4ThreatTimeline(replayResultId, { + endpointRoot, + fetcher: async (input) => { + requested = String(input); + return new Response(JSON.stringify({ + schema_version: "missioncore.recorded-spatial-evidence-timeline/v1", + result_id: replayResultId, + recorded_source: { + session_id: "20260720T065719Z_viewer_live", + source_id: "RAVNOVES00", + representation_id: "registered-map-increment-v1", + synchronization: "host-arrival-best-effort", + }, + image_width: 800, + image_height: 600, + frame_count: 4489, + frame_times_ns: frameTimesNs, + timeline_start_seconds: 35.421857292, + timeline_end_seconds: 484.221857292, + nominal_frame_interval_seconds: 0.1, + nominal_rate_hz: 10, + max_chunk_frames: 24, + point_sample_limit: 4096, + maximum_source_points_per_frame: 3092, + point_delivery: "exact-current-increment", + camera_point_delivery: "factory-kb4-projected-current-increment", + camera_point_sample_limit: 4096, + world_state_delivery: "source-paced-latest-wins", + world_state_frame_count: 4481, + superseded_frame_count: 8, + local_surface_visualization: { + derivation: "bounded-registered-increment-accumulation", + window_seconds: 2, + voxel_size_m: 0.1, + radius_m: 12, + point_limit: 20000, + authority: "visual-derived", + }, + 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, + occupied_voxel_size_m: 0.45, + prediction_horizon_seconds: 5, + }, + authority: "replay-simulated", + }), { status: 200 }); + }, + }); + assert.equal(requested, `${endpointRoot}/${replayResultId}/timeline`); + assert.equal(timeline.cameraPointDelivery, "factory-kb4-projected-current-increment"); + assert.equal(timeline.worldStateFrameCount, 4481); + assert.equal(timeline.supersededFrameCount, 8); + + const chunk = await fetchM4ThreatTimelineChunk(replayResultId, 1, 1, { + endpointRoot, + fetcher: async (input) => { + requested = String(input); + return new Response(JSON.stringify({ + schema_version: "missioncore.recorded-spatial-evidence-chunk/v1", + result_id: replayResultId, + start_sequence: 1, + frame_count: 1, + next_sequence: 2, + frames: [timelineFrame(1, 35.521857292, { + world_state_available: false, + terminal_outcome: "superseded", + camera_projected_points_xyd: [[100.5, 200.25, 3.75]], + camera_projected_source_count: 847, + camera_projected_point_count: 1, + camera_projected_sample_count: 1, + camera_projection: "factory-kb4-exact", + camera_url: `${endpointRoot}/${replayResultId}/timeline/frames/1/camera`, + })], + authority: "replay-simulated", + }), { status: 200 }); + }, + }); + assert.match(requested, new RegExp(`^${endpointRoot}/${replayResultId}/timeline/chunk`)); + assert.equal(chunk.frames[0].worldStateAvailable, false); + assert.equal(chunk.frames[0].terminalOutcome, "superseded"); + assert.deepEqual(chunk.frames[0].cameraProjectedPointsXyd[0], [100.5, 200.25, 3.75]); + assert.equal(chunk.frames[0].cameraProjection, "factory-kb4-exact"); +}); + test("M4.6 local SLAM surface reprojects registered increments into the active body frame", () => { const frames = [ timelineFrame(0, 10, { @@ -453,11 +547,12 @@ test("recorded VIDEO clock cannot reverse an explicit operator pause", () => { }); test("M4.6 viewer keeps media and spatial panes on one playback clock", async () => { - const [visual, visualCss, imageScene, videoScene, metricScene] = await Promise.all([ + const [visual, visualCss, imageScene, videoScene, pointOverlay, metricScene] = await Promise.all([ readFile(new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), "utf8"), readFile(new URL("../src/styles/m4-replay-threat.css", import.meta.url), "utf8"), readFile(new URL("../src/components/laboratory/RecordedEvidenceImageScene.tsx", import.meta.url), "utf8"), readFile(new URL("../src/components/laboratory/RecordedEvidenceVideoScene.tsx", import.meta.url), "utf8"), + readFile(new URL("../src/components/laboratory/RecordedEvidencePointCloudOverlay.tsx", import.meta.url), "utf8"), readFile(new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url), "utf8"), ]); assert.match(visual, /\s*POINTS\s*