From 13c35ff4463b9d579224f529400b57d45e602859 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Jul 2026 19:54:22 +0300 Subject: [PATCH] fix(polygon): unify dataset review workflow --- apps/control-station/src/App.tsx | 26 +- .../src/core/polygon/runArchive.ts | 5 +- apps/control-station/src/productModel.ts | 16 +- .../control-station/src/styles/responsive.css | 13 +- .../control-station/src/styles/workspaces.css | 145 +++++++ .../workspaces/DatasetGatewayWorkspace.tsx | 54 ++- .../src/workspaces/GooseDatasetReview.tsx | 263 ++++++++++-- .../src/workspaces/PolygonRunWorkspace.tsx | 374 ------------------ .../src/workspaces/Workspaces.tsx | 5 - .../test/groundQualification.test.mjs | 66 ++++ .../test/polygonRunArchive.test.mjs | 15 +- docs/01_IMPLEMENTATION_PLAN.md | 7 +- docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md | 63 +-- docs/14_LIDAR_DATASET_GATEWAY.md | 18 +- docs/runbooks/SIM_S1C_LIVE_WORKER.md | 7 +- 15 files changed, 568 insertions(+), 509 deletions(-) delete mode 100644 apps/control-station/src/workspaces/PolygonRunWorkspace.tsx diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 2d14ad3..98f8b91 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -150,7 +150,7 @@ function mergeViewerSettings( export default function App() { const runtime = useMissionRuntime(); const { selection } = useDevicePluginHost(); - const polygonRunRoute = useMemo( + const polygonDatasetRoute = useMemo( () => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search), [], ); @@ -160,7 +160,7 @@ export default function App() { }); const [activeRoot, setActiveRoot] = useState( - polygonRunRoute.active ? "polygon" : null, + polygonDatasetRoute.active ? "polygon" : null, ); const [sourceUrl, setSourceUrl] = useState(""); const [recordedReplay, setRecordedReplay] = useState(null); @@ -194,7 +194,7 @@ export default function App() { const replayActiveRef = useRef(false); const sceneSettingsCommitterActiveRef = useRef(true); const sceneSettingsCommitterRef = useRef | null>(null); - const polygonRunRouteOpenedRef = useRef(false); + const polygonDatasetRouteOpenedRef = useRef(false); const currentRoot = rootById(activeRoot); const visibleRoots = roots; @@ -214,10 +214,10 @@ export default function App() { replayActiveRef.current = replayActive; useEffect(() => { - if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return; - polygonRunRouteOpenedRef.current = true; - workspace.openView("polygon-run"); - }, [polygonRunRoute.active, workspace]); + if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return; + polygonDatasetRouteOpenedRef.current = true; + workspace.openView("polygon-datasets"); + }, [polygonDatasetRoute.active, workspace]); if (!sceneSettingsCommitterRef.current) { sceneSettingsCommitterRef.current = createLatestAsyncCommitter({ @@ -415,7 +415,7 @@ export default function App() { const selectRoot = (rootId: RootId) => { setActiveRoot(rootId); if (rootId === "polygon") { - workspace.openView("polygon-run"); + workspace.openView("polygon-datasets"); workspace.openNavigation(); return; } @@ -694,7 +694,12 @@ export default function App() { - {rootWorkspaces.length} рабочих поверхностей + + {rootWorkspaces.length}{" "} + {rootWorkspaces.length === 1 + ? "рабочая поверхность" + : "рабочих поверхностей"} + } /> @@ -740,8 +745,6 @@ export default function App() { ) : null} - ) : activeDefinition.kind === "polygon-run" ? ( - Virtual only ) : activeDefinition.kind === "polygon-datasets" ? ( Offline evaluation ) : ( @@ -772,7 +775,6 @@ export default function App() { livePerceptionLayers={livePerceptionLayers} onLivePerceptionLayersChange={changeLivePerceptionLayers} observationLayout={observationLayout} - polygonRunRoute={polygonRunRoute} deviceLabel={selection?.model.displayName ?? null} spatialControls={selection?.SpatialControlsView ? { diff --git a/apps/control-station/src/core/polygon/runArchive.ts b/apps/control-station/src/core/polygon/runArchive.ts index f6b2eb4..e7e5879 100644 --- a/apps/control-station/src/core/polygon/runArchive.ts +++ b/apps/control-station/src/core/polygon/runArchive.ts @@ -552,7 +552,10 @@ export function decodePolygonRunDetail(payload: unknown): PolygonRunDetail { export function resolvePolygonRunRoute(search: string): PolygonRunRoute { const parameters = new URLSearchParams(search); const workspaceValues = parameters.getAll("workspace"); - if (workspaceValues.length !== 1 || workspaceValues[0] !== "polygon-run") { + if ( + workspaceValues.length !== 1 + || !["polygon-datasets", "polygon-run"].includes(workspaceValues[0]) + ) { return { active: false, runId: null, error: null }; } const runValues = parameters.getAll("run"); diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 645431a..76f4963 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -19,8 +19,7 @@ export type WorkspaceKind = | "missions" | "catalog" | "lidar-quality" - | "polygon-datasets" - | "polygon-run"; + | "polygon-datasets"; export type CapabilityStatus = "active" | "ready" | "contract" | "later"; @@ -134,7 +133,7 @@ export const roots: RootDefinition[] = [ label: "Полигон", title: "Полигон", eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ", - description: "Симуляционные, replay и shadow-прогоны с воспроизводимыми доказательствами.", + description: "Датасеты и виртуальные среды для воспроизводимой проверки алгоритмов.", statement: "Проверять алгоритмы и поведение машины на версионированных входах.", accent: "КВАЛИФИКАЦИЯ И ДОКАЗАТЕЛЬСТВА", }, @@ -150,17 +149,6 @@ export const roots: RootDefinition[] = [ ]; export const workspaces: WorkspaceDefinition[] = [ - { - id: "polygon-run", - root: "polygon", - label: "Прогоны", - title: "Прогоны", - eyebrow: "ПОЛИГОН / ПРОГОНЫ", - description: "История simulation, dataset replay и shadow-запусков с выбором конкретного прогона.", - icon: "activity", - kind: "polygon-run", - groups: [], - }, { id: "polygon-datasets", root: "polygon", diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index a6f54e2..4b4a4de 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -200,7 +200,8 @@ .polygon-review-lead, .polygon-review-player__header, .polygon-review-order, - .polygon-review-summary > header { + .polygon-review-summary > header, + .dataset-fragment-analysis > header { align-items: stretch; flex-direction: column; } @@ -217,6 +218,16 @@ grid-template-columns: 1fr; } + .dataset-fragment-analysis__verdict { + min-width: 0; + justify-items: start; + } + + .dataset-fragment-comparison, + .dataset-fragment-risks { + grid-template-columns: 1fr; + } + .polygon-run-picker, .dataset-result-select, .dataset-sequence-select, diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 939801d..751b315 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -1441,6 +1441,151 @@ line-height: 1.45; } +.dataset-fragment-analysis { + display: grid; + gap: 0.55rem; + border-radius: 1rem; + background: var(--station-panel); + padding: 0.9rem 1rem 1rem; +} + +.dataset-fragment-analysis > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.dataset-fragment-analysis h3, +.dataset-fragment-analysis p { + margin: 0; +} + +.dataset-fragment-analysis h3 { + margin-top: 0.28rem; + color: var(--nodedc-text-primary); + font-size: 1rem; +} + +.dataset-fragment-analysis p { + max-width: 48rem; + margin-top: 0.3rem; + color: var(--nodedc-text-muted); + font-size: 0.6rem; + line-height: 1.5; +} + +.dataset-fragment-analysis__verdict { + display: grid; + min-width: 14rem; + justify-items: end; + gap: 0.12rem; +} + +.dataset-fragment-analysis__verdict strong { + color: var(--nodedc-text-primary); + font-size: 1.35rem; +} + +.dataset-fragment-analysis__verdict span, +.dataset-fragment-analysis__verdict small { + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.dataset-fragment-comparison { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.32rem; +} + +.dataset-fragment-comparison article { + display: grid; + grid-template-columns: minmax(7rem, 1fr) auto auto auto auto; + align-items: center; + gap: 0.5rem; + border-radius: 0.78rem; + background: rgb(255 255 255 / 0.035); + padding: 0.7rem 0.75rem; +} + +.dataset-fragment-comparison article > span, +.dataset-fragment-comparison small { + color: var(--nodedc-text-muted); + font-size: 0.52rem; +} + +.dataset-fragment-comparison article > div { + display: grid; + gap: 0.08rem; +} + +.dataset-fragment-comparison strong { + color: var(--nodedc-text-primary); + font-size: 0.72rem; +} + +.dataset-fragment-comparison i { + color: var(--nodedc-text-muted); + font-style: normal; +} + +.dataset-fragment-comparison em { + color: rgb(var(--nodedc-danger-rgb)); + font-size: 0.58rem; + font-style: normal; + font-weight: 650; +} + +.dataset-fragment-comparison em[data-positive="true"] { + color: var(--nodedc-text-primary); +} + +.dataset-fragment-risks { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.32rem; +} + +.dataset-fragment-risks button { + display: grid; + min-width: 0; + gap: 0.18rem; + border: 0; + border-radius: 0.78rem; + outline: 0; + background: rgb(255 255 255 / 0.025); + color: inherit; + padding: 0.68rem 0.75rem; + font: inherit; + text-align: left; + cursor: pointer; +} + +.dataset-fragment-risks button:hover, +.dataset-fragment-risks button:focus-visible { + background: rgb(255 255 255 / 0.075); +} + +.dataset-fragment-risks button:disabled { + cursor: default; + opacity: 0.45; +} + +.dataset-fragment-risks span, +.dataset-fragment-risks small { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.52rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dataset-fragment-risks strong { + color: var(--nodedc-text-primary); + font-size: 0.82rem; +} + .polygon-review-summary, .polygon-review-unavailable, .polygon-review-analysis, diff --git a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx index 6309d10..0633b3f 100644 --- a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx +++ b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx @@ -125,18 +125,17 @@ export function DatasetGatewayWorkspace() { ЗАЧЕМ ЭТО НУЖНО

Независимая проверка алгоритмов

- Датасет — это вход квалификационного прогона. Мы подаём известные - размеченные данные в выбранный pipeline, измеряем ошибки и сохраняем - воспроизводимое решение. Он не подмешивается в диагностику - реального сенсора и не - заменяет closed-loop симуляцию. + Датасет содержит известные размеченные данные. Мы показываем + конкретный фрагмент, результаты каждого алгоритма и ошибки + относительно разметки. Он не подмешивается в диагностику реального + сенсора и не заменяет closed-loop симуляцию.

-
    -
  1. 01Выбрать данные
  2. -
  3. 02Запустить pipeline
  4. -
  5. 03Сравнить с разметкой
  6. -
  7. 04Принять решение
  8. +
      +
    1. 01Выбрать фрагмент
    2. +
    3. 02Посмотреть сканы
    4. +
    5. 03Сравнить алгоритмы
    6. +
    7. 04Проверить риски
    @@ -179,9 +178,10 @@ export function DatasetGatewayWorkspace() { PUBLIC · OFF-ROAD · LABELED

    {catalog.source.displayName}

    - Validation split: восемь отдельных поездок и 961 разреженный - размеченный оборот VLS-128. Это коллекция native scans для - perception, а не непрерывное видео или траектория машины. + Validation split: восемь фрагментов исходных записей и 961 + разреженный размеченный оборот VLS-128. Это коллекция native + scans для perception, а не непрерывное видео или траектория + машины.

    @@ -232,20 +232,12 @@ export function DatasetGatewayWorkspace() { disabled={catalog.source.admissionStatus !== "frame-ready"} title={ catalog.source.admissionStatus === "frame-ready" - ? "Открыть все доступные последовательности и scans" - : "Размеченные scans ещё не импортированы" + ? "Открыть фрагменты, сканы и результаты анализа" + : "Размеченные сканы ещё не импортированы" } onClick={() => setPreviewOpen((value) => !value)} > - {previewOpen ? "Закрыть источник" : "Открыть источник"} - - @@ -254,20 +246,20 @@ export function DatasetGatewayWorkspace() { {previewOpen && selectedRunId ? ( <> - {gooseRuns.length > 0 ? ( + {gooseRuns.length > 1 ? (
    - НАЛОЖЕНИЕ РЕЗУЛЬТАТА - Квалификационный прогон + РЕЗУЛЬТАТЫ ОБРАБОТКИ + Версия анализа
    @@ -278,8 +270,8 @@ export function DatasetGatewayWorkspace() { ) : previewOpen ? (
    Review недоступен -

    Источник принят, но покадровый preview не опубликован

    -

    {runError ?? "Нет квалификационного прогона с bounded frame-pack."}

    +

    Датасет принят, но результаты анализа не опубликованы

    +

    {runError ?? "Нет совместимой версии покадрового анализа."}

    ) : null} diff --git a/apps/control-station/src/workspaces/GooseDatasetReview.tsx b/apps/control-station/src/workspaces/GooseDatasetReview.tsx index a630801..b48a6f0 100644 --- a/apps/control-station/src/workspaces/GooseDatasetReview.tsx +++ b/apps/control-station/src/workspaces/GooseDatasetReview.tsx @@ -31,18 +31,37 @@ export interface GooseDatasetSequence { maximumGapSeconds: number; } +interface SequenceMetricComparison { + current: number; + patchwork: number; + delta: number; +} + +export interface GooseDatasetSequenceAnalysis { + groundIou: SequenceMetricComparison; + naturalGroundRecall: SequenceMetricComparison; + obstacleNonGroundRecall: SequenceMetricComparison; + patchworkBetterFrames: number; + currentBetterFrames: number; + unchangedFrames: number; + weakestPatchworkFrame: GroundReviewFrameSummary; + worstRegressionFrame: GroundReviewFrameSummary; + largestGapFrame: GroundReviewFrameSummary | null; + largestGapSeconds: number; +} + const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [ ["intensity", "Исходный скан"], ["ground-truth", "Ground truth"], - ["current", "Current"], + ["current", "Текущий алгоритм"], ["candidate", "Patchwork++"], - ["disagreement", "Ошибки Current"], + ["disagreement", "Ошибки текущего"], ["candidate-disagreement", "Ошибки Patchwork++"], ]; function errorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) return error.message; - return "Не удалось открыть последовательности GOOSE."; + return "Не удалось открыть фрагменты GOOSE."; } function secondsBetween( @@ -78,6 +97,83 @@ export function groupGooseReviewFrames( }); } +function average( + frames: GroundReviewFrameSummary[], + value: (frame: GroundReviewFrameSummary) => number, +): number { + return frames.reduce((total, frame) => total + value(frame), 0) / frames.length; +} + +function comparison( + frames: GroundReviewFrameSummary[], + value: (frame: GroundReviewFrameSummary) => { + current: number; + patchwork: number; + }, +): SequenceMetricComparison { + const current = average(frames, (frame) => value(frame).current); + const patchwork = average(frames, (frame) => value(frame).patchwork); + return { current, patchwork, delta: patchwork - current }; +} + +export function analyzeGooseDatasetSequence( + sequence: GooseDatasetSequence, +): GooseDatasetSequenceAnalysis { + const first = sequence.frames[0]; + if (!first) { + throw new Error("Фрагмент датасета не содержит сканов."); + } + let weakestPatchworkFrame = first; + let worstRegressionFrame = first; + let largestGapFrame: GroundReviewFrameSummary | null = null; + let largestGapSeconds = 0; + let patchworkBetterFrames = 0; + let currentBetterFrames = 0; + let unchangedFrames = 0; + + sequence.frames.forEach((frame, index) => { + if (frame.patchwork.groundIou < weakestPatchworkFrame.patchwork.groundIou) { + weakestPatchworkFrame = frame; + } + if (frame.groundIouDelta < worstRegressionFrame.groundIouDelta) { + worstRegressionFrame = frame; + } + if (frame.groundIouDelta > 0.000_001) patchworkBetterFrames += 1; + else if (frame.groundIouDelta < -0.000_001) currentBetterFrames += 1; + else unchangedFrames += 1; + + if (index > 0) { + const gapSeconds = secondsBetween(sequence.frames[index - 1], frame); + if (gapSeconds > largestGapSeconds) { + largestGapSeconds = gapSeconds; + largestGapFrame = frame; + } + } + }); + + return { + groundIou: comparison(sequence.frames, (frame) => ({ + current: frame.current.groundIou, + patchwork: frame.patchwork.groundIou, + })), + naturalGroundRecall: comparison(sequence.frames, (frame) => ({ + current: frame.current.naturalGroundRecall, + patchwork: frame.patchwork.naturalGroundRecall, + })), + obstacleNonGroundRecall: comparison(sequence.frames, (frame) => ({ + current: frame.current.obstacleNonGroundRecall, + patchwork: frame.patchwork.obstacleNonGroundRecall, + })), + patchworkBetterFrames, + currentBetterFrames, + unchangedFrames, + weakestPatchworkFrame, + worstRegressionFrame, + largestGapFrame, + largestGapSeconds, + }; +} + function sequenceLabel(sequenceId: string): string { const date = sequenceId.slice(0, 10); const title = sequenceId.slice(11).replaceAll("_", " "); @@ -130,9 +226,11 @@ function frameScene(frame: GroundReviewFrame | null) { function modeExplanation(mode: LidarGroundViewMode): string { if (mode === "intensity") return "Один sensor-frame оборот VLS-128; цвет — remission."; if (mode === "ground-truth") return "Публичная point-aligned разметка GOOSE."; - if (mode === "current") return "Результат Current для этого исходного скана."; + if (mode === "current") return "Результат текущего алгоритма для этого исходного скана."; if (mode === "candidate") return "Результат Patchwork++ для этого исходного скана."; - if (mode === "disagreement") return "Красным отмечены ошибки Current относительно labels."; + if (mode === "disagreement") { + return "Красным отмечены ошибки текущего алгоритма относительно разметки."; + } return "Красным отмечены ошибки Patchwork++ относительно labels."; } @@ -146,6 +244,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) { const [playing, setPlaying] = useState(false); const [frameLoading, setFrameLoading] = useState(false); const frameCache = useRef(new Map()); + const playerRef = useRef(null); useEffect(() => { const controller = new AbortController(); @@ -175,6 +274,10 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) { const selectedSequence = sequences.find( (sequence) => sequence.datasetSequenceId === selectedSequenceId, ) ?? sequences[0] ?? null; + const sequenceAnalysis = useMemo( + () => selectedSequence ? analyzeGooseDatasetSequence(selectedSequence) : null, + [selectedSequence], + ); const selectedPosition = Math.max( 0, selectedSequence?.frames.findIndex((frame) => frame.frameId === selectedFrameId) ?? 0, @@ -252,6 +355,18 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) { setSelectedFrameId(selectedSequence.frames[bounded]?.frameId ?? null); }; + const inspectFrame = ( + frame: GroundReviewFrameSummary | null, + viewMode: LidarGroundViewMode, + ) => { + if (!selectedSequence || !frame) return; + selectPosition(selectedSequence.frames.indexOf(frame)); + setMode(viewMode); + window.requestAnimationFrame(() => { + playerRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }; + const selectSequence = (sequenceId: string) => { const sequence = sequences.find((candidate) => ( candidate.datasetSequenceId === sequenceId @@ -276,7 +391,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) { return (
    Читаем индекс -

    Открываем последовательности GOOSE

    +

    Открываем фрагменты GOOSE

    Загружаем только bounded preview, исходный архив остаётся на worker.

    ); @@ -286,23 +401,24 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
    - СТРУКТУРА ИСХОДНИКА -

    8 отдельных поездок, а не одно видео

    + СОДЕРЖИМОЕ ДАТАСЕТА +

    8 отдельных фрагментов исходных записей

    Validation ZIP содержит {review.frameCount} разреженных размеченных - LiDAR-оборотов. Между ними нет непрерывной траектории машины. Play ниже — - ускоренный просмотр выбранной поездки, а не воспроизведение реального времени. + LiDAR-оборотов. Каждый фрагмент относится к отдельному эпизоду съёмки. + Play ниже — ускоренный просмотр доступных сканов выбранного фрагмента, + а не воспроизведение движения машины в реальном времени.

    -
    Последовательностей
    {sequences.length}
    +
    Фрагментов
    {sequences.length}
    Размеченных сканов
    {review.frameCount}
    Preview
    {review.previewPoints.toLocaleString("ru-RU")} точек
    - + - {selectedSequence.frames.length} размеченных сканов · интервал{" "} + {selectedSequence.frames.length} сканов · интервалы{" "} {formatGap(selectedSequence.minimumGapSeconds)}–{ formatGap(selectedSequence.maximumGapSeconds) } · охват {formatDuration(selectedSequence.durationSeconds)}
    -
    +
    @@ -352,7 +468,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
    ) : null}
    {selectedSequence.frames.map((frame, index) => (
    + {sequenceAnalysis ? ( +
    +
    +
    + РЕЗУЛЬТАТЫ ЭТОГО ФРАГМЕНТА +

    Что изменилось после Patchwork++

    +

    + Средние значения рассчитаны только по{" "} + {selectedSequence.frames.length} сканам выбранного фрагмента. + Они не смешиваются с остальными частями датасета. +

    +
    +
    + {formatDelta(sequenceAnalysis.groundIou.delta)} + средний Ground IoU + + лучше на {sequenceAnalysis.patchworkBetterFrames} · хуже на{" "} + {sequenceAnalysis.currentBetterFrames} · без изменения на{" "} + {sequenceAnalysis.unchangedFrames} + +
    +
    + +
    + {([ + ["Ground IoU", sequenceAnalysis.groundIou], + ["Natural ground recall", sequenceAnalysis.naturalGroundRecall], + ["Obstacle preservation", sequenceAnalysis.obstacleNonGroundRecall], + ] as const).map(([label, metric]) => ( +
    + {label} +
    + Текущий + {formatPercent(metric.current)} +
    + +
    + Patchwork++ + {formatPercent(metric.patchwork)} +
    + = 0 ? "true" : "false"}> + {formatDelta(metric.delta)} + +
    + ))} +
    + +
    + + + +
    +
    + ) : null} +

    Для настоящего движения машины, odometry и синхронного sensor playback нужен отдельный raw ROS bag GOOSE с localization. Он не входит в принятый 3.3 ГБ diff --git a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx deleted file mode 100644 index b4589ed..0000000 --- a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import { useEffect, useState } from "react"; -import { Button, StatusBadge } from "@nodedc/ui-react"; - -import { - fetchGroundQualification, - type GroundQualification, -} from "../core/polygon/groundQualification"; -import { - fetchPolygonRunCatalog, - fetchPolygonRunDetail, - type PolygonRunCatalog, - type PolygonRunDetail, - type PolygonRunRoute, - type PolygonRunState, -} from "../core/polygon/runArchive"; - -interface PolygonRunWorkspaceProps { - route: PolygonRunRoute; -} - -const stateLabels: Record = { - admitted: "Допущен", - starting: "Запускается", - running: "Выполняется", - paused: "На паузе", - stopping: "Останавливается", - completed: "Завершён", - failed: "Ошибка", - aborted: "Прерван", -}; - -function stateTone( - state: PolygonRunState, -): "success" | "accent" | "warning" | "danger" | "neutral" { - if (state === "completed") return "success"; - if (state === "running") return "accent"; - if (state === "failed" || state === "aborted") return "danger"; - if (state === "starting" || state === "stopping" || state === "paused") return "warning"; - return "neutral"; -} - -function runKindLabel(kind: string): string { - if (kind === "simulation-closed-loop" || kind === "simulation_closed_loop") { - return "Closed-loop simulation"; - } - if (kind === "dataset-replay" || kind === "replay_shadow") { - return "Dataset replay"; - } - if (kind === "shadow") return "Live shadow"; - return kind; -} - -function formatTimestamp(value: string | null): string { - if (!value) return "—"; - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "medium", - timeStyle: "medium", - }).format(new Date(value)); -} - -function formatBytes(value: number): string { - if (value === 0) return "0 Б"; - const units = ["Б", "КБ", "МБ", "ГБ"]; - const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); - return `${(value / 1024 ** exponent).toLocaleString("ru-RU", { - maximumFractionDigits: 1, - })} ${units[exponent]}`; -} - -function formatPercent(value: number): string { - return new Intl.NumberFormat("ru-RU", { - style: "percent", - maximumFractionDigits: 1, - }).format(value); -} - -function formatDelta(value: number): string { - const points = value * 100; - return `${points >= 0 ? "+" : ""}${points.toLocaleString("ru-RU", { - maximumFractionDigits: 1, - })} п.п.`; -} - -function errorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) return error.message; - return "Не удалось прочитать прогон."; -} - -export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { - const [catalog, setCatalog] = useState(null); - const [detail, setDetail] = useState(null); - const [qualification, setQualification] = useState(null); - const [selectedRunId, setSelectedRunId] = useState(route.runId); - const [loading, setLoading] = useState(route.error === null); - const [error, setError] = useState(route.error); - const [reloadGeneration, setReloadGeneration] = useState(0); - - useEffect(() => { - if (route.error) { - setLoading(false); - setError(route.error); - return; - } - const controller = new AbortController(); - setLoading(true); - setError(null); - void (async () => { - try { - const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal }); - if (controller.signal.aborted) return; - setCatalog(nextCatalog); - const targetRunId = selectedRunId - ?? route.runId - ?? nextCatalog.items[0]?.runId - ?? null; - if (!targetRunId) { - setDetail(null); - setQualification(null); - return; - } - const nextDetail = await fetchPolygonRunDetail(targetRunId, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - const nextQualification = await fetchGroundQualification(targetRunId, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - setSelectedRunId(targetRunId); - setDetail(nextDetail); - setQualification(nextQualification); - } catch (loadError) { - if (controller.signal.aborted) return; - setDetail(null); - setQualification(null); - setError(errorMessage(loadError)); - } finally { - if (!controller.signal.aborted) setLoading(false); - } - })(); - return () => controller.abort(); - }, [reloadGeneration, route.error, route.runId, selectedRunId]); - - if (loading && !detail) { - return ( -

    -
    - ПОЛИГОН / ПРОГОНЫ -

    Открываем историю прогонов

    -

    Читаем каталог и выбранный воспроизводимый запуск.

    -
    -
    - ); - } - - if (error) { - return ( -
    -
    - Данные недоступны -

    Не удалось открыть прогон

    -

    {error}

    - -
    -
    - ); - } - - if (!detail) { - return ( -
    -
    - ПОЛИГОН / ПРОГОНЫ -

    Прогонов пока нет

    -

    Здесь появятся simulation, dataset replay и shadow-запуски.

    -
    -
    - ); - } - - const { run } = detail; - return ( -
    -
    -
    - ИСТОРИЯ ПОЛИГОНА - Выбранный прогон -
    - - {catalog?.total ?? 0} в архиве -
    - -
    -
    - {runKindLabel(run.kind).toUpperCase()} -

    {qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}

    -

    - {qualification - ? "Результат алгоритмического replay. Сам исходный датасет и его sequences открываются в разделе «Датасеты»." - : "Воспроизводимая запись запуска Полигона."} -

    -
    - {stateLabels[run.state]} -
    - - {qualification ? ( - <> -
    -
    - ВХОД ПРОГОНА -

    GOOSE 3D · validation

    -

    - Восемь отдельных поездок, 961 разреженный размеченный LiDAR scan. - Это не closed-loop simulation и не непрерывная запись движения машины. -

    -
    - -
    - -
    -
    -
    - РЕЗУЛЬТАТ ПРОГОНА -

    Current против Patchwork++

    -
    - - {qualification.decision.passed ? "27 / 27 проверок" : "Есть провалы"} - -
    -

    - Оба алгоритма независимо обработали одни и те же 961 scans. - Покадровая визуальная проверка теперь находится рядом с самим входом - в «Датасетах», а не притворяется симуляцией машины. -

    -
    -
    - Ground IoU - {formatPercent(qualification.current.micro.groundIou)} - - {formatPercent(qualification.patchwork.micro.groundIou)} - {formatDelta( - qualification.patchwork.micro.groundIou - - qualification.current.micro.groundIou, - )} -
    -
    - Natural ground recall - {formatPercent( - qualification.current.micro.naturalGroundRecall, - )} - - {formatPercent( - qualification.patchwork.micro.naturalGroundRecall, - )} - {formatDelta( - qualification.patchwork.micro.naturalGroundRecall - - qualification.current.micro.naturalGroundRecall, - )} -
    -
    - Latency p95 - {qualification.current.latencyMs.p95.toLocaleString("ru-RU", { - maximumFractionDigits: 1, - })} мс - - {qualification.patchwork.latencyMs.p95.toLocaleString("ru-RU", { - maximumFractionDigits: 1, - })} мс - на scan -
    -
    -
    - -
    - Acceptance и деградации -
    -
    - ACCEPTANCE GATES - {qualification.checks.map((check) => ( -

    -