diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 7e575bc..a49ecc0 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -184,6 +184,7 @@ margin-left: auto; } +.m4-replay-threat-visual__review-controls, .m4-replay-threat-visual__pane-layer-controls, .m4-replay-threat-visual__single-pane-controls { display: flex; @@ -195,11 +196,14 @@ scrollbar-width: none; } +.m4-replay-threat-visual__review-controls, +.m4-replay-threat-visual__review-controls > *, .m4-replay-threat-visual__single-pane-controls, .m4-replay-threat-visual__single-pane-controls > * { pointer-events: auto; } +.m4-replay-threat-visual__review-controls::-webkit-scrollbar, .m4-replay-threat-visual__pane-layer-controls::-webkit-scrollbar, .m4-replay-threat-visual__single-pane-controls::-webkit-scrollbar { display: none; diff --git a/apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx b/apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx index 79f5d36..01dc0eb 100644 --- a/apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx @@ -5,7 +5,7 @@ import { LaboratoryWorkTemplate, } from "../../components/laboratory/LaboratoryPresentation"; import type { M48SmallStaticRegressionResult } from "../../core/laboratory/m48SmallStaticRegression"; -import { M48SmallStaticRegressionVisual } from "./M48SmallStaticRegressionVisual"; +import { M48SmallStaticRegressionEvidence } from "./M48SmallStaticRegressionEvidence"; function percent(value: number): string { return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`; @@ -30,7 +30,7 @@ export function M48SmallStaticPassageRegressionResultView({ status={status} statusTone={result.accepted ? "success" : "warning"} facts={[ - { label: "Конфигурация", value: `${rigLabel} RIGHT · camera + prediction-free current spatial evidence` }, + { label: "Конфигурация", value: `${rigLabel} RIGHT · Canonical Reference Graph · VIDEO/CAMERA/3D/PLAN/SEMANTICS` }, { label: "Пайплайн", value: result.pipelineId }, { label: "Эксперимент", value: result.experimentId }, { label: "Прогон", value: `${result.runLabel} · immutable ${result.resultId}` }, @@ -47,7 +47,8 @@ export function M48SmallStaticPassageRegressionResultView({ executionClass: "deterministic", pipelineId: result.pipelineId, components: [ - { kind: "source", name: result.packId, version: "immutable Worker 006 pack", role: "frozen candidate output + exact camera/current-spatial evidence", identitySha256: result.packId.split("-").at(-1) ?? null }, + { kind: "source", name: result.packId, version: "immutable Worker 006 pack", role: "frozen candidate output + exact assisted anchors", identitySha256: result.packId.split("-").at(-1) ?? null }, + { kind: "source", name: result.referenceGraphLabResultId, version: "approved canonical template", role: "synchronized VIDEO/CAMERA/3D/PLAN/SEMANTICS evidence", identitySha256: result.referenceGraphLabResultId.split("-").at(-1) ?? null }, { kind: "source", name: "M4.8 assisted correction snapshot", version: "revision-bound", role: "operator-added anchors · not independent truth", identitySha256: null }, { kind: "algorithm", name: "small-static assisted-anchor comparator", version: "v1", role: "exact-frame class-free IoU regression", identitySha256: result.resultId.split("-").at(-1) ?? null }, ], @@ -55,8 +56,8 @@ export function M48SmallStaticPassageRegressionResultView({ /> )} evidence={( - - + + )} result={( diff --git a/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionEvidence.tsx b/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionEvidence.tsx new file mode 100644 index 0000000..786c68d --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionEvidence.tsx @@ -0,0 +1,96 @@ +import { useEffect, useMemo, useState } from "react"; +import { Icon } from "@nodedc/ui-react"; + +import { fetchM47ReferenceGraphLab, type M47ReferenceGraphLabResult } from "../../core/laboratory/m47ReferenceGraph"; +import { + fetchM48SmallStaticRegressionCases, + type M48SmallStaticRegressionCaseSummary, + type M48SmallStaticRegressionResult, +} from "../../core/laboratory/m48SmallStaticRegression"; +import { + M4ReplayThreatVisual, + type M4ReplayThreatReviewAnchor, +} from "./M4ReplayThreatVisual"; + +function message(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "Каноническое graph evidence M4.8R1 недоступно."; +} + +export function M48SmallStaticRegressionEvidence({ + result, +}: { + result: M48SmallStaticRegressionResult; +}) { + const [graph, setGraph] = useState(null); + const [cases, setCases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + void Promise.all([ + fetchM47ReferenceGraphLab({ + resultId: result.referenceGraphLabResultId, + signal: controller.signal, + }), + fetchM48SmallStaticRegressionCases(result.resultId, { + signal: controller.signal, + }), + ]) + .then(([nextGraph, nextCases]) => { + if (controller.signal.aborted) return; + if (nextCases.some((item) => item.sequence < 1 || item.sequence > nextGraph.frames.expected)) { + throw new Error("M4.8R1 anchor вышел за immutable timeline Canonical Reference Graph."); + } + setGraph(nextGraph); + setCases(nextCases); + }) + .catch((caught: unknown) => { + if (!controller.signal.aborted) setError(message(caught)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [result.referenceGraphLabResultId, result.resultId]); + + const reviewAnchors = useMemo(() => ( + cases.map((item) => ({ + id: item.anchorId, + sourceSequence: item.sequence - 1, + extentXyxyNormalized: item.anchorExtentXyxy, + matchedAtThreshold: item.matchedAtThreshold, + })) + ), [cases]); + + if (loading) { + return ( +
+
+ ); + } + if (error || !graph) { + return ( +
+ + {error ?? "Canonical Reference Graph не связан с прогоном M4.8R1."} +
+ ); + } + return ( + + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx b/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx deleted file mode 100644 index 2e21f33..0000000 --- a/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react"; - -import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; -import { - fetchM48ReviewSourceCatalog, - type M48ReviewTracklet, -} from "../../core/laboratory/m48ObjectCentricQuality"; -import { - fetchM48SmallStaticRegressionCase, - fetchM48SmallStaticRegressionCases, - type M48SmallStaticRegressionCase, - type M48SmallStaticRegressionCaseSummary, -} from "../../core/laboratory/m48SmallStaticRegression"; -import { - M48BlindClipPlayer, - type M48BlindEvidenceMode, -} from "./annotation/M48BlindClipPlayer"; -import { M48EvidenceModeRail } from "./annotation/M48EvidenceModeControls"; -import { useM48SpatialClipPlayback } from "./annotation/useM48SpatialClipPlayback"; - -function message(error: unknown): string { - return error instanceof Error && error.message.trim() - ? error.message - : "M4.8R1 evidence недоступно."; -} - -function exactFrameTracklets(item: M48SmallStaticRegressionCase): readonly M48ReviewTracklet[] { - const sequence = item.anchor.sequence; - const state = ( - objectId: string, - extentXyxy: readonly [number, number, number, number], - geometryAssociation: M48ReviewTracklet["stateSegments"][number]["geometryAssociation"], - freshness: M48ReviewTracklet["stateSegments"][number]["freshness"], - motion: M48ReviewTracklet["stateSegments"][number]["motion"], - threat: M48ReviewTracklet["stateSegments"][number]["threat"], - criticalCorridorObstacle: boolean, - ): M48ReviewTracklet => ({ - objectId, - firstSequence: sequence, - lastSequence: sequence, - keyframes: [{ sequence, extentXyxy, visibility: "visible" }], - stateSegments: [{ - startSequence: sequence, - endSequence: sequence, - geometryAssociation, - freshness, - motion, - threat, - criticalCorridorObstacle, - }], - notes: null, - }); - - return [ - state( - `ASSISTED · ${item.anchor.objectId}`, - item.anchor.extentXyxy, - item.anchor.geometryAssociation, - item.anchor.freshness, - item.anchor.motion, - item.anchor.threat, - item.anchor.requiresAvoidanceOrClearance, - ), - ...item.comparison.workerObjects.map((object) => state( - `WORKER · ${object.predictionId}`, - object.extentXyxy, - object.geometryAssociation, - object.freshness, - object.motion, - object.threat, - false, - )), - ]; -} - -export function M48SmallStaticRegressionVisual({ resultId }: { resultId: string }) { - const [cases, setCases] = useState([]); - const [caseIndex, setCaseIndex] = useState(0); - const [item, setItem] = useState(null); - const [catalog, setCatalog] = useState> | null>(null); - const [sequence, setSequence] = useState(1); - const [mode, setMode] = useState("camera"); - const [cameraVisible, setCameraVisible] = useState(true); - const [expanded, setExpanded] = useState(false); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const controller = new AbortController(); - setLoading(true); - setError(null); - void fetchM48SmallStaticRegressionCases(resultId, { signal: controller.signal }) - .then((next) => { - if (!controller.signal.aborted) setCases(next); - }) - .catch((caught: unknown) => !controller.signal.aborted && setError(message(caught))) - .finally(() => !controller.signal.aborted && setLoading(false)); - return () => controller.abort(); - }, [resultId]); - - useEffect(() => { - const selected = cases[caseIndex]; - if (!selected) { - setItem(null); - return; - } - const controller = new AbortController(); - setLoading(true); - setError(null); - void fetchM48SmallStaticRegressionCase(resultId, selected.anchorId, { signal: controller.signal }) - .then((next) => { - if (controller.signal.aborted) return; - setItem(next); - setSequence(next.anchor.sequence); - }) - .catch((caught: unknown) => !controller.signal.aborted && setError(message(caught))) - .finally(() => !controller.signal.aborted && setLoading(false)); - return () => controller.abort(); - }, [caseIndex, cases, resultId]); - - useEffect(() => { - if (!item) return; - const controller = new AbortController(); - setCatalog(null); - void fetchM48ReviewSourceCatalog(item.packId, { signal: controller.signal }) - .then((next) => !controller.signal.aborted && setCatalog(next)) - .catch((caught: unknown) => !controller.signal.aborted && setError(message(caught))); - return () => controller.abort(); - }, [item?.packId]); - - const clip = item && catalog - ? catalog.clips.find((candidate) => candidate.clipId === item.anchor.clipId) ?? null - : null; - const spatialEnabled = Boolean( - catalog?.evidenceCapabilities.currentPointCloudBodyXyzM - && catalog.evidenceCapabilities.rig - && catalog.evidenceCapabilities.virtualCorridor, - ); - const spatial = useM48SpatialClipPlayback({ - packId: item?.packId ?? "", - clip, - sequence, - enabled: mode !== "camera" && spatialEnabled, - }); - const tracklets = useMemo( - () => item ? exactFrameTracklets(item) : [], - [item], - ); - - return ( - - setCaseIndex((current) => (current - 1 + cases.length) % cases.length)} - > - - - setCaseIndex((current) => (current + 1) % cases.length)} - > - - - - )} - overlay={item ? ( -
- - {item.comparison.matchedAtThreshold ? "WORKER RECALL" : "WORKER MISS"} - - {caseIndex + 1}/{cases.length} · {item.anchor.clipId} · кадр {item.anchor.sequence} - ASSISTED-якорь, не independent truth · best IoU {item.comparison.bestIou.toFixed(3)} -
- ) : null} - > -
- {loading ? ( -
-
- ) : error ? ( -
{error}
- ) : clip && catalog?.cameraPlayback && item ? ( - undefined} - onSelectedObjectIdChange={() => undefined} - onTrackletsChange={() => undefined} - /> - ) : ( -
- Точный источник M4.8R1 недоступен. -
- )} - {catalog ? ( - - ) : null} -
-
- ); -} diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index b15d3ef..0f47d33 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -3,6 +3,7 @@ import { Button, Icon, IconButton, + Select, SegmentedControl, SplitPane, type SplitPaneOrientation, @@ -90,12 +91,23 @@ export interface M4ReplayThreatSemanticLayer { taxonomy: readonly E47SemanticClass[]; } +export interface M4ReplayThreatReviewAnchor { + id: string; + sourceSequence: number; + extentXyxyNormalized: readonly [number, number, number, number]; + matchedAtThreshold: boolean; +} + +const EMPTY_REVIEW_ANCHORS: readonly M4ReplayThreatReviewAnchor[] = []; + export function M4ReplayThreatVisual({ resultId, semantic, + reviewAnchors = EMPTY_REVIEW_ANCHORS, }: { resultId: string; semantic?: M4ReplayThreatSemanticLayer; + reviewAnchors?: readonly M4ReplayThreatReviewAnchor[]; }) { const [mediaMode, setMediaMode] = useState("video"); const [spatialMode, setSpatialMode] = useState(null); @@ -111,6 +123,7 @@ export function M4ReplayThreatVisual({ : "vertical" )); const [expanded, setExpanded] = useState(false); + const [selectedReviewAnchorIndex, setSelectedReviewAnchorIndex] = useState(0); const metricSceneRef = useRef(null); const metadata = useM4ThreatTimelineMetadata(resultId); const playbackRange = useMemo(() => metadata.timeline ? ({ @@ -120,6 +133,8 @@ export function M4ReplayThreatVisual({ const playbackController = useRecordedEvidencePlayback(playbackRange, { clock: mediaMode === "video" ? "external" : "animation", }); + const seekPlayback = playbackController.seek; + const setPlaybackPlaying = playbackController.setPlaying; const timelineFrame = useM4ThreatTimelineFrame({ resultId, timeline: metadata.timeline, @@ -209,7 +224,50 @@ export function M4ReplayThreatVisual({ && timelineFrame.activeSequence !== null && frame.sequence !== timelineFrame.activeSequence, ); - const activeBoxes = useMemo(() => boxes(frame?.cameraProposals ?? []), [frame]); + const reviewAnchorIdentity = useMemo( + () => reviewAnchors.map((anchor) => `${anchor.id}:${anchor.sourceSequence}`).join("|"), + [reviewAnchors], + ); + const initializedReviewAnchorRef = useRef(""); + useEffect(() => { + const timeline = metadata.timeline; + if (!timeline || !reviewAnchors.length) return; + const identity = `${resultId}:${reviewAnchorIdentity}`; + if (initializedReviewAnchorRef.current === identity) return; + initializedReviewAnchorRef.current = identity; + setSelectedReviewAnchorIndex(0); + const startTimeNs = timeline.frameTimesNs[reviewAnchors[0]!.sourceSequence]; + if (typeof startTimeNs === "number") { + setPlaybackPlaying(false); + seekPlayback(startTimeNs / 1_000_000_000); + } + }, [metadata.timeline, resultId, reviewAnchorIdentity, reviewAnchors, seekPlayback, setPlaybackPlaying]); + const reviewAnchorBoxes = useMemo(() => { + const timeline = metadata.timeline; + if (!frame || !timeline) return []; + return reviewAnchors + .filter((anchor) => anchor.sourceSequence === frame.sequence) + .map((anchor) => { + const [left, top, right, bottom] = anchor.extentXyxyNormalized; + return { + boxXyxy: [ + left * timeline.imageWidth, + top * timeline.imageHeight, + right * timeline.imageWidth, + bottom * timeline.imageHeight, + ] as const, + label: anchor.matchedAtThreshold + ? "РУЧНОЙ ЯКОРЬ · ПОКРЫТ WORKER 006" + : "РУЧНОЙ ЯКОРЬ · ПРОПУСК WORKER 006", + tone: anchor.matchedAtThreshold ? "success" as const : "danger" as const, + dashed: true, + }; + }); + }, [frame, metadata.timeline, reviewAnchors]); + const activeBoxes = useMemo( + () => [...boxes(frame?.cameraProposals ?? []), ...reviewAnchorBoxes], + [frame, reviewAnchorBoxes], + ); const semanticClasses = useMemo( () => semantic?.taxonomy.map((item) => ({ id: item.classId, @@ -429,11 +487,54 @@ export function M4ReplayThreatVisual({ ? spatialMode ? spatialLayerControls : mediaMode ? mediaLayerControls : null : null; - const actions = singlePaneControls ? ( + const selectReviewAnchor = (index: number) => { + const timeline = metadata.timeline; + if (!timeline || !reviewAnchors.length) return; + const normalizedIndex = (index + reviewAnchors.length) % reviewAnchors.length; + const anchor = reviewAnchors[normalizedIndex]!; + const sourceTimeNs = timeline.frameTimesNs[anchor.sourceSequence]; + if (typeof sourceTimeNs !== "number") return; + setSelectedReviewAnchorIndex(normalizedIndex); + setPlaybackPlaying(false); + seekPlayback(sourceTimeNs / 1_000_000_000); + }; + + const reviewControls = reviewAnchors.length ? ( +
+ selectReviewAnchor(selectedReviewAnchorIndex - 1)} + > + + + selectReviewAnchor(selectedReviewAnchorIndex + 1)} + > + + +