feat(lab): visualize dual evidence replay
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user