feat(lab): visualize dual evidence replay

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 19:18:14 +03:00
parent b0d0bc8d7f
commit de19229895
17 changed files with 1825 additions and 115 deletions
@@ -39,6 +39,7 @@ import { E46GRectifiedDetectorBakeoffResultView } from "./E46GRectifiedDetectorB
import { E46HFullRectifiedFrontReplayResultView } from "./E46HFullRectifiedFrontReplayResult";
import { E46IGroundingDinoFullReplayResultView } from "./E46IGroundingDinoFullReplayResult";
import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult";
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
export { isAdvancedLaboratoryWorkId };
export type { AdvancedLaboratoryWorkId };
@@ -81,6 +82,9 @@ export function AdvancedLaboratoryResult({
failedSessionId: string | null;
replayError: string | null;
}) {
if (workId === "m4-replay-threat" && results.m4Threat) {
return <M4ReplayThreatResultView rigLabel={rigLabel} result={results.m4Threat} />;
}
if (workId === "l3-pointpillars-visual-audit" && results.l3) {
return <L3PointPillarsResult result={results.l3} />;
}
@@ -1,9 +1,12 @@
import { useEffect, useMemo, useRef } from "react";
import { useMemo } from "react";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "../../components/RecordedFmp4Player";
import {
RecordedEvidenceVideoScene,
type RecordedEvidenceBox,
} from "../../components/laboratory/RecordedEvidenceVideoScene";
import {
selectE46CVideoFrame,
type E46CMotionState,
@@ -11,26 +14,10 @@ import {
} from "../../core/laboratory/e46cFullReplayWorldTracks";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
function color(
host: HTMLElement,
token: string,
fallback: readonly [number, number, number],
alpha = 1,
): string {
const channels = getComputedStyle(host)
.getPropertyValue(token)
.trim()
.match(/[\d.]+/g)
?.slice(0, 3)
.map(Number);
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function stateColor(host: HTMLElement, state: E46CMotionState): string {
if (state === "dynamic") return color(host, "--nodedc-accent-rgb", [232, 56, 126]);
if (state === "static") return color(host, "--nodedc-success-rgb", [181, 255, 90]);
return color(host, "--nodedc-warning-rgb", [255, 197, 92]);
function stateTone(state: E46CMotionState): RecordedEvidenceBox["tone"] {
if (state === "dynamic") return "accent";
if (state === "static") return "success";
return "warning";
}
function stateLabel(state: E46CMotionState): string {
@@ -50,97 +37,40 @@ export function E46CRecordedVideoScene({
playback: RecordedObservationPlayback;
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const frame = useMemo(
() => selectE46CVideoFrame(overlay.frames, playback.currentSeconds),
[overlay.frames, playback.currentSeconds],
);
useEffect(() => {
const host = hostRef.current;
const canvas = canvasRef.current;
if (!host || !canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const render = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, width, height);
if (!frame) return;
const scale = Math.min(
width / overlay.imageWidth,
height / overlay.imageHeight,
);
const drawWidth = overlay.imageWidth * scale;
const drawHeight = overlay.imageHeight * scale;
const offsetX = (width - drawWidth) / 2;
const offsetY = (height - drawHeight) / 2;
for (const item of frame.objects) {
const [left, top, right, bottom] = item.boxXyxy;
const x = offsetX + left * scale;
const y = offsetY + top * scale;
const boxWidth = (right - left) * scale;
const boxHeight = (bottom - top) * scale;
const stroke = stateColor(host, item.motionState);
context.strokeStyle = stroke;
context.lineWidth = Math.max(1.5, 2 * scale);
context.setLineDash(item.cameraEvidenceCurrent ? [] : [5, 4]);
context.strokeRect(x, y, boxWidth, boxHeight);
context.setLineDash([]);
const identity = `S${item.routeTrackId}${
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
}`;
const label = `${identity} · ${item.displayCategory} · ${stateLabel(
const boxes = useMemo<readonly RecordedEvidenceBox[]>(() => (
frame?.objects.map((item) => {
const identity = `S${item.routeTrackId}${
item.worldTrackId === null ? "" : `→W${item.worldTrackId}`
}`;
return {
boxXyxy: item.boxXyxy,
label: `${identity} · ${item.displayCategory} · ${stateLabel(
item.motionState,
)} · ${Math.round(item.score * 100)}%`;
const fontSize = Math.max(9, 10 * scale);
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
const labelWidth = context.measureText(label).width + 8;
const labelHeight = fontSize + 6;
const labelX = Math.min(
offsetX + drawWidth - labelWidth,
Math.max(offsetX, x),
);
const labelY = Math.max(offsetY, y - labelHeight);
context.fillStyle = color(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
context.fillRect(labelX, labelY, labelWidth, labelHeight);
context.fillStyle = stroke;
context.fillText(label, labelX + 4, labelY + fontSize + 1);
}
};
const observer = new ResizeObserver(render);
observer.observe(host);
render();
return () => observer.disconnect();
}, [frame, overlay.imageHeight, overlay.imageWidth]);
)} · ${Math.round(item.score * 100)}%`,
tone: stateTone(item.motionState),
dashed: !item.cameraEvidenceCurrent,
};
}) ?? []
), [frame]);
return (
<div className="e46c-video-scene" ref={hostRef}>
<RecordedFmp4Player
source={source}
playback={playback}
interactive
prepare
onPlaybackChange={onPlaybackChange}
/>
<canvas
ref={canvasRef}
role="img"
aria-label={
frame
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
: "E46C recorded video overlay"
}
/>
</div>
<RecordedEvidenceVideoScene
source={source}
playback={playback}
imageWidth={overlay.imageWidth}
imageHeight={overlay.imageHeight}
boxes={boxes}
ariaLabel={
frame
? `E46C video frame ${frame.frameIndex}: ${frame.objects.length} route objects`
: "E46C recorded video overlay"
}
onPlaybackChange={onPlaybackChange}
/>
);
}
@@ -0,0 +1,138 @@
import {
LaboratoryEvidence,
LaboratoryResultSummary,
LaboratorySummary,
LaboratoryWorkTemplate,
} from "../../components/laboratory/LaboratoryPresentation";
import type { M4ThreatReplayResult } from "../../core/laboratory/m4ReplayThreat";
import { formatNumber } from "../../presentation";
import { M4ReplayThreatVisual } from "./M4ReplayThreatVisual";
export function M4ReplayThreatResultView({
rigLabel,
result,
}: {
rigLabel: string;
result: M4ThreatReplayResult;
}) {
const metrics = result.metrics;
const totalAssessments = Object.values(metrics.decisions).reduce(
(sum, value) => sum + value,
0,
);
return (
<LaboratoryWorkTemplate
summary={(
<LaboratorySummary
title="M4.6 · dual-evidence threat replay"
description="Camera и LiDAR дают независимые доказательства, после чего один source-neutral слой оценивает пересечение виртуального коридора, ближайшее сближение и TTC. Ни один сенсор не назначен first."
status="4489/4489 · replay-simulated · accepted"
statusTone="warning"
facts={[
{
label: "Конфигурация",
value: `${rigLabel} · RIGHT camera + LiDAR geometry · recorded replay`,
},
{
label: "Виртуальный корпус",
value: `${result.configuration.virtualBodyM[0]}×${result.configuration.virtualBodyM[1]} м · LiDAR ${result.configuration.nominalSensorHeightM} м`,
},
{
label: "Коридор",
value: `${result.configuration.forwardCorridorM} м · horizon ${result.configuration.predictionHorizonSeconds} с`,
},
{
label: "Визуал",
value: "4489-frame VIDEO · 32 exact CAMERA/3D/PLAN samples",
},
]}
brief={{
question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?",
approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Geometry-only объекты получают метрическую оценку; camera-only и stale/held остаются unknown. Отдельная матрица из 9 детерминированных сценариев проверяет статические, сближающиеся и расходящиеся случаи.",
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only наблюдений учтены; ${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only оценок не потеряны. Критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. Постоянная скорость — ограниченная модель, а independent object truth остаётся следующим gate.",
}}
method={{
completeness: "complete",
executionClass: "hybrid",
pipelineId: "dual-evidence-replay-threat/v1",
components: [
{
kind: "source",
name: result.sourceResultIds.detector,
version: "frozen camera proposals",
role: "независимое image-space evidence без safety authority",
identitySha256: result.sourceResultIds.detector.split("-").at(-1) ?? null,
},
{
kind: "source",
name: result.sourceResultIds.geometry,
version: "frozen metric geometry",
role: "LiDAR occupied components и camera association",
identitySha256: result.sourceResultIds.geometry.split("-").at(-1) ?? null,
},
{
kind: "source",
name: result.sourceResultIds.temporal,
version: "frozen temporal object map",
role: "current / held / expired и bounded motion history",
identitySha256: result.sourceResultIds.temporal.split("-").at(-1) ?? null,
},
{
kind: "algorithm",
name: "dual-evidence virtual corridor",
version: result.profileId,
role: "classless corridor intersection, closest approach and TTC",
identitySha256: result.resultId.split("-").at(-1) ?? null,
},
],
}}
/>
)}
evidence={(
<LaboratoryEvidence
eyebrow="M4.6 VISUAL EVIDENCE · VIDEO / CAMERA / 3D / PLAN"
title="Синхронный контроль рамок, расстояний, облака точек и виртуального коридора"
kind="diagnostic-model"
resizable
>
<M4ReplayThreatVisual resultId={result.resultId} />
</LaboratoryEvidence>
)}
result={(
<LaboratoryResultSummary
title="Dual-evidence слой готов к следующей CV-итерации на recorded replay"
status="Replay gate accepted · physical authority withheld"
statusTone="warning"
metrics={[
{
label: "Replay frames",
value: "4489/4489",
hint: `${formatNumber(metrics.runtime.framesPerSecond, 1)} FPS offline`,
},
{
label: "Metric evidence",
value: metrics.evidence.currentMetric.toLocaleString("ru-RU"),
hint: `${metrics.reasonCounts["geometry-only-evidence"]?.toLocaleString("ru-RU") ?? "0"} geometry-only`,
},
{
label: "Threat / clear",
value: `${metrics.decisions.threat.toLocaleString("ru-RU")} / ${metrics.decisions["not-threat"].toLocaleString("ru-RU")}`,
hint: `${totalAssessments.toLocaleString("ru-RU")} assessments accounted`,
},
{
label: "Critical false-safe",
value: String(metrics.fixtures.criticalFalseNotThreat),
hint: `${metrics.fixtures.passed}/${metrics.fixtures.total} deterministic fixtures passed`,
},
]}
conclusion={{
proved: "На неизменяемом RAVNOVES00 каждый metric, stale/held и camera-only объект получил ровно одну консервативную оценку. Geometry-only препятствия участвуют в threat-решении без класса, camera-only и просроченные данные не превращаются в safe. Видео, точные camera samples и метрическое 3D-доказательство доступны в одном viewer.",
notProved: "Не доказаны live realtime, измеренная геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.",
decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.",
}}
/>
)}
/>
);
}
@@ -0,0 +1,385 @@
import { useEffect, useMemo, useState } from "react";
import { Icon, IconButton, Select } from "@nodedc/ui-react";
import type { RecordedObservationPlayback } from "../../components/RecordedFmp4Player";
import {
LaboratoryMetricEvidenceScene,
type LaboratoryMetricSceneMode,
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
import {
RecordedEvidenceVideoScene,
type RecordedEvidenceBox,
} from "../../components/laboratory/RecordedEvidenceVideoScene";
import {
fetchM4ThreatVideoOverlay,
fetchM4ThreatVisual,
fetchM4ThreatVisualIndex,
selectM4ThreatVideoFrame,
type M4ThreatCameraProposal,
type M4ThreatVideoOverlay,
type M4ThreatVisualFrame,
type M4ThreatVisualIndexItem,
} from "../../core/laboratory/m4ReplayThreat";
import { recordedObservationSources } from "../../core/observation/recordedObservationSources";
import { replayObservationSession } from "../../core/observation/sessionArchive";
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
type M4ThreatViewMode = "video" | "camera" | LaboratoryMetricSceneMode;
function toneForProposal(proposal: M4ThreatCameraProposal): RecordedEvidenceBox["tone"] {
if (proposal.threatDecision === "threat") return "danger";
if (proposal.threatDecision === "not-threat") return "success";
if (proposal.threatDecision === "unknown") return "warning";
return proposal.occupiedSupport ? "accent" : "warning";
}
function proposalLabel(proposal: M4ThreatCameraProposal): string {
const decision = proposal.threatDecision ?? "unknown";
if (proposal.rangeM === null) return decision;
const range = `${proposal.rangeM.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`;
return `${range} · ${decision}`;
}
function boxes(proposals: readonly M4ThreatCameraProposal[]): readonly RecordedEvidenceBox[] {
return proposals.map((proposal) => ({
boxXyxy: proposal.bboxXyxy,
label: proposalLabel(proposal),
tone: toneForProposal(proposal),
dashed: !proposal.occupiedSupport,
}));
}
function message(error: unknown, fallback: string): string {
return error instanceof Error && error.message.trim() ? error.message : fallback;
}
export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
const [mode, setMode] = useState<M4ThreatViewMode>("video");
const [expanded, setExpanded] = useState(false);
const [index, setIndex] = useState<readonly M4ThreatVisualIndexItem[]>([]);
const [ordinal, setOrdinal] = useState(1);
const [frame, setFrame] = useState<M4ThreatVisualFrame | null>(null);
const [sampleLoading, setSampleLoading] = useState(true);
const [sampleError, setSampleError] = useState<string | null>(null);
const [videoOverlay, setVideoOverlay] = useState<M4ThreatVideoOverlay | null>(null);
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
const [videoLoading, setVideoLoading] = useState(false);
const [videoError, setVideoError] = useState<string | null>(null);
const [videoPlayback, setVideoPlayback] = useState<RecordedObservationPlayback>({
currentSeconds: 0,
playing: false,
});
useEffect(() => {
const controller = new AbortController();
setSampleLoading(true);
setSampleError(null);
void fetchM4ThreatVisualIndex(resultId, { signal: controller.signal })
.then((items) => {
if (!controller.signal.aborted) setIndex(items);
})
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
setSampleError(message(caught, "Индекс визуальных кадров M4.6 недоступен."));
}
});
return () => controller.abort();
}, [resultId]);
useEffect(() => {
const controller = new AbortController();
setSampleLoading(true);
setSampleError(null);
setFrame(null);
void fetchM4ThreatVisual(resultId, ordinal, { signal: controller.signal })
.then((next) => {
if (!controller.signal.aborted) setFrame(next);
})
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
setSampleError(message(caught, "Метрический visual M4.6 недоступен."));
}
})
.finally(() => {
if (!controller.signal.aborted) setSampleLoading(false);
});
return () => controller.abort();
}, [ordinal, resultId]);
useEffect(() => {
if ((mode !== "video" && mode !== "camera") || (videoOverlay && videoSource)) return;
const controller = new AbortController();
setVideoLoading(true);
setVideoError(null);
void (async () => {
const overlay = await fetchM4ThreatVideoOverlay(resultId, {
signal: controller.signal,
});
const replay = await replayObservationSession(overlay.recordedSourceSessionId, {
signal: controller.signal,
});
if (replay.kind !== "ready") {
throw new Error("RIGHT-видео RAVNOVES00 ещё готовится к воспроизведению.");
}
const source = recordedObservationSources(replay.launch).find(
(candidate) =>
candidate.modality === "video" &&
candidate.semanticChannelId === "camera.video.recorded",
);
const delivery = source?.delivery?.kind === "recorded-fmp4-manifest"
? source.delivery
: null;
if (
!source ||
!delivery ||
delivery.timelineStartSeconds !== overlay.timelineStartSeconds ||
delivery.timelineEndSeconds < overlay.timelineEndSeconds
) {
throw new Error("RIGHT-видео не совпало с временным контрактом M4.6.");
}
if (controller.signal.aborted) return;
setVideoOverlay(overlay);
setVideoSource(source);
setVideoPlayback({
currentSeconds: overlay.timelineStartSeconds,
playing: false,
});
})()
.catch((caught: unknown) => {
if (!controller.signal.aborted) {
setVideoError(message(caught, "Видео-доказательство M4.6 недоступно."));
}
})
.finally(() => {
if (!controller.signal.aborted) setVideoLoading(false);
});
return () => controller.abort();
}, [mode, resultId, videoOverlay, videoSource]);
useEffect(() => {
if (mode !== "camera" || !frame) return;
setVideoPlayback({
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
playing: false,
});
}, [frame, mode]);
const activeVideoFrame = useMemo(
() => videoOverlay
? selectM4ThreatVideoFrame(videoOverlay.frames, videoPlayback.currentSeconds)
: null,
[videoOverlay, videoPlayback.currentSeconds],
);
const activeProposals = mode === "camera"
? frame?.cameraProposals ?? []
: activeVideoFrame?.cameraProposals ?? [];
const activeBoxes = useMemo(() => boxes(activeProposals), [activeProposals]);
const selectedItem = index.find((item) => item.ordinal === ordinal) ?? null;
const threatObstacles = frame?.metricObstacles.filter(
(item) => item.assessment.decision === "threat",
) ?? [];
const nearest = frame?.metricObstacles
.map((item) => item.assessment.closestApproachM)
.filter((value): value is number => value !== null)
.sort((left, right) => left - right)[0] ?? null;
const seekVideo = (seconds: number) => {
if (!videoOverlay) return;
setVideoPlayback({
currentSeconds: Math.min(
videoOverlay.timelineEndSeconds,
Math.max(videoOverlay.timelineStartSeconds, seconds),
),
playing: false,
});
};
const navigate = (offset: -1 | 1) => {
const count = Math.max(index.length, 32);
setOrdinal((current) => ((current - 1 + offset + count) % count) + 1);
};
const actions = mode === "video" ? (
<div className="l3-visual-audit__actions">
<div className="l3-visual-audit__pagination">
<IconButton label="Назад на 5 секунд" onClick={() => seekVideo(videoPlayback.currentSeconds - 5)}>
<Icon name="chevron-left" size={16} />
</IconButton>
<IconButton label="Вперёд на 5 секунд" onClick={() => seekVideo(videoPlayback.currentSeconds + 5)}>
<Icon name="chevron-right" size={16} />
</IconButton>
</div>
<Select
label="Перейти к метрическому sample M4.6"
value={String(ordinal)}
options={(index.length ? index : Array.from({ length: 32 }, (_, position) => ({
ordinal: position + 1,
sequence: position,
frameId: "",
sourceTimeNs: 0,
metricObstacleCount: 0,
cameraProposalCount: 0,
pointCloudSampleCount: 0,
}))).map((item) => ({
value: String(item.ordinal),
label: `${item.ordinal}/32 · frame ${item.sequence} · ${item.metricObstacleCount} metric objects`,
}))}
variant="split"
menuWidth="anchor"
searchable
searchPlaceholder="Найти sample"
onChange={(value) => {
const nextOrdinal = Number(value);
const target = index.find((item) => item.ordinal === nextOrdinal);
setOrdinal(nextOrdinal);
if (target) seekVideo(target.sourceTimeNs / 1_000_000_000);
}}
/>
</div>
) : (
<div className="l3-visual-audit__actions">
<div className="l3-visual-audit__pagination">
<IconButton label="Предыдущий sample M4.6" onClick={() => navigate(-1)}>
<Icon name="chevron-left" size={16} />
</IconButton>
<IconButton label="Следующий sample M4.6" onClick={() => navigate(1)}>
<Icon name="chevron-right" size={16} />
</IconButton>
</div>
<Select
label="Выбрать sample M4.6"
value={String(ordinal)}
options={index.map((item) => ({
value: String(item.ordinal),
label: `${item.ordinal}/32 · frame ${item.sequence} · ${item.metricObstacleCount} metric · ${item.cameraProposalCount} camera`,
}))}
variant="split"
menuWidth="anchor"
searchable
searchPlaceholder="Найти sample"
onChange={(value) => setOrdinal(Number(value))}
/>
</div>
);
const overlay = mode === "video" && videoOverlay ? (
<div className="l3-visual-audit__overlay l3-visual-audit__overlay--video">
<div>
<span>RAVNOVES00 · recorded RIGHT</span>
<strong>
+{(videoPlayback.currentSeconds - videoOverlay.timelineStartSeconds).toFixed(1)} с
{activeVideoFrame ? ` · frame ${activeVideoFrame.frameIndex}` : ""}
</strong>
<small>{videoPlayback.playing ? "воспроизведение" : "пауза / seek"}</small>
</div>
<div>
<span>Camera evidence</span>
<strong>{activeVideoFrame?.cameraProposals.length ?? 0} рамок · distance при LiDAR support</strong>
<small>пунктир = camera-only · всегда unknown</small>
</div>
<div>
<span>Replay decision</span>
<strong>
{activeVideoFrame?.decisionCounts.threat ?? 0} threat · {activeVideoFrame?.decisionCounts["not-threat"] ?? 0} clear · {activeVideoFrame?.decisionCounts.unknown ?? 0} unknown
</strong>
<small>REPLAY-SIMULATED · не live и не safety authority</small>
</div>
</div>
) : frame ? (
<div className="l3-visual-audit__overlay">
<div>
<span>RAVNOVES00 · exact replay sample</span>
<strong>frame {frame.sequence} · sample {frame.ordinal}/32</strong>
<small>{(frame.sourceTimeNs / 1_000_000_000).toFixed(3)} с · {selectedItem?.frameId}</small>
</div>
<div>
<span>Dual evidence</span>
<strong>{frame.metricObstacles.length} metric · {frame.cameraProposals.length} camera</strong>
<small>{frame.pointCloudSampleCount}/{frame.pointCloudSourceCount} LiDAR points shown</small>
</div>
<div>
<span>Virtual corridor</span>
<strong>{threatObstacles.length} threat · nearest {nearest === null ? "—" : `${nearest.toFixed(2)} м`}</strong>
<small>{frame.corridor.forwardLengthM} м · body {frame.rig.lengthM}×{frame.rig.widthM} м · REPLAY-SIMULATED</small>
</div>
</div>
) : undefined;
let content;
if (mode === "video" || mode === "camera") {
content = videoLoading ? (
<div className="l3-visual-audit__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Связываем 4489 решений M4.6 с RIGHT-видео</span>
</div>
) : videoError || !videoOverlay || !videoSource ? (
<div className="l3-visual-audit__state" role="status">
<Icon name="alert" size={18} />
<span>{videoError ?? "Видео-доказательство M4.6 недоступно."}</span>
</div>
) : (
<RecordedEvidenceVideoScene
source={videoSource}
playback={videoPlayback}
imageWidth={videoOverlay.imageWidth}
imageHeight={videoOverlay.imageHeight}
boxes={activeBoxes}
ariaLabel={
mode === "camera"
? `M4.6 exact camera sample ${ordinal}: ${activeBoxes.length} proposals`
: `M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`
}
onPlaybackChange={setVideoPlayback}
/>
);
} else {
content = sampleLoading ? (
<div className="l3-visual-audit__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Открываем синхронное облако точек M4.6</span>
</div>
) : sampleError || !frame ? (
<div className="l3-visual-audit__state" role="status">
<Icon name="alert" size={18} />
<span>{sampleError ?? "Метрический visual M4.6 недоступен."}</span>
</div>
) : (
<LaboratoryMetricEvidenceScene
pointCloudBodyXyzM={frame.pointCloudBodyXyzM}
obstacles={frame.metricObstacles.map((obstacle) => ({
id: obstacle.componentId,
decision: obstacle.assessment.decision,
state: obstacle.state,
centroidBodyXyzM: obstacle.centroidBodyXyzM,
cellCentersBodyXyzM: obstacle.cellCentersBodyXyzM,
}))}
rig={frame.rig}
corridor={frame.corridor}
mode={mode}
label={`M4.6 metric point cloud, frame ${frame.sequence}`}
/>
);
}
return (
<div className="l3-visual-audit m4-replay-threat-visual">
<LaboratoryEvidenceViewer
label="M4.6 dual-evidence replay: video, camera and metric 3D"
mode={mode}
modes={[
{ value: "video", label: "VIDEO" },
{ value: "camera", label: "CAMERA" },
{ value: "3d", label: "3D" },
{ value: "plan", label: "PLAN" },
]}
expanded={expanded}
onModeChange={setMode}
onExpandedChange={setExpanded}
actions={actions}
overlay={overlay}
>
{content}
</LaboratoryEvidenceViewer>
</div>
);
}
@@ -6,6 +6,7 @@ import type {
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
export type LaboratoryProfileId =
| "rig-dual-evidence-virtual-corridor-v1"
| "rig-camera-local-surface-v1"
| "rig-track-geometry-temporal-v1"
| "rig-ravnoves-perception-gate-v1"
@@ -56,6 +57,13 @@ interface KnownWorkDefinition {
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
"m4-replay-threat": {
profileId: "rig-dual-evidence-virtual-corridor-v1",
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
experimentId: "m4-ravnoves00-dual-evidence-threat",
experimentName: "RAVNOVES00 dual-evidence threat qualification",
variantName: "M4.6 · virtual corridor replay · VIDEO/CAMERA/3D",
},
"e28-local-surface": {
profileId: "rig-camera-local-surface-v1",
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera-first + local-surface LiDAR`,
@@ -18,6 +18,7 @@ function mergeResults(
next: AdvancedLaboratoryResults,
): AdvancedLaboratoryResults {
return {
m4Threat: next.m4Threat ?? current.m4Threat,
l3: next.l3 ?? current.l3,
l31: next.l31 ?? current.l31,
l32: next.l32 ?? current.l32,