diff --git a/apps/control-station/src/core/polygon/groundQualification.ts b/apps/control-station/src/core/polygon/groundQualification.ts index 9a65f94..7cec1b6 100644 --- a/apps/control-station/src/core/polygon/groundQualification.ts +++ b/apps/control-station/src/core/polygon/groundQualification.ts @@ -73,6 +73,9 @@ export interface GroundReviewFrameMetrics { export interface GroundReviewFrameSummary { sequence: number; frameId: string; + datasetSequenceId: string; + datasetFrameNumber: number; + sensorTimestampNs: string; sourcePointCount: number; pointCount: number; current: GroundReviewFrameMetrics; @@ -108,6 +111,8 @@ type QualificationFetch = ( const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const SHA256 = /^[a-f0-9]{64}$/; +const GOOSE_FRAME_ID = + /^([A-Za-z0-9][A-Za-z0-9_-]{0,95})__([0-9]{4})_([0-9]{16,20})$/; function record(value: unknown, field: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -373,7 +378,7 @@ function reviewFrameMetrics(value: unknown, field: string): GroundReviewFrameMet export function decodeGroundReview(payload: unknown): GroundReview { const source = record(payload, "ground review"); if ( - source.schema_version !== "missioncore.polygon-ground-review/v1" + source.schema_version !== "missioncore.polygon-ground-review/v2" || source.access !== "read-only" ) { throw new GroundQualificationContractError("Неизвестная схема покадрового просмотра."); @@ -386,9 +391,36 @@ export function decodeGroundReview(payload: unknown): GroundReview { if (sequence !== index) { throw new GroundQualificationContractError("Кадры просмотра идут не по порядку."); } + const frameId = id(frame.frame_id, `frames[${index}].frame_id`); + const frameIdentity = parseGooseFrameId(frameId); + const datasetSequenceId = id( + frame.dataset_sequence_id, + `frames[${index}].dataset_sequence_id`, + ); + const datasetFrameNumber = integer( + frame.dataset_frame_number, + `frames[${index}].dataset_frame_number`, + ); + const sensorTimestampNs = text( + frame.sensor_timestamp_ns, + `frames[${index}].sensor_timestamp_ns`, + 20, + ); + if ( + datasetSequenceId !== frameIdentity.datasetSequenceId + || datasetFrameNumber !== frameIdentity.datasetFrameNumber + || sensorTimestampNs !== frameIdentity.sensorTimestampNs + ) { + throw new GroundQualificationContractError( + `frames[${index}] противоречит идентичности GOOSE-кадра.`, + ); + } return { sequence, - frameId: id(frame.frame_id, `frames[${index}].frame_id`), + frameId, + datasetSequenceId, + datasetFrameNumber, + sensorTimestampNs, sourcePointCount: integer( frame.source_point_count, `frames[${index}].source_point_count`, @@ -432,6 +464,26 @@ export function decodeGroundReview(payload: unknown): GroundReview { }; } +export interface GooseFrameIdentity { + datasetSequenceId: string; + datasetFrameNumber: number; + sensorTimestampNs: string; +} + +export function parseGooseFrameId(frameId: string): GooseFrameIdentity { + const match = GOOSE_FRAME_ID.exec(frameId); + if (!match) { + throw new GroundQualificationContractError( + "frame_id не содержит GOOSE sequence/frame/timestamp.", + ); + } + return { + datasetSequenceId: match[1], + datasetFrameNumber: Number(match[2]), + sensorTimestampNs: match[3], + }; +} + export function decodeGroundReviewFrame(payload: unknown): GroundReviewFrame { const source = record(payload, "ground review frame"); if ( diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index ab4323d..645431a 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -156,7 +156,7 @@ export const workspaces: WorkspaceDefinition[] = [ label: "Прогоны", title: "Прогоны", eyebrow: "ПОЛИГОН / ПРОГОНЫ", - description: "Покадровый replay, сравнение алгоритмов и история квалификационных прогонов.", + description: "История simulation, dataset replay и shadow-запусков с выбором конкретного прогона.", icon: "activity", kind: "polygon-run", groups: [], diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index 74ef661..a6f54e2 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -97,6 +97,10 @@ } @media (max-width: 1040px) { + .dataset-sequence-review__truth { + grid-template-columns: 1fr; + } + .polygon-review-summary > div:last-child, .polygon-review-analysis__content, .polygon-run-technical__content { @@ -213,6 +217,20 @@ grid-template-columns: 1fr; } + .polygon-run-picker, + .dataset-result-select, + .dataset-sequence-select, + .polygon-run-input { + align-items: stretch; + flex-direction: column; + } + + .polygon-run-picker select, + .dataset-result-select select, + .dataset-sequence-select select { + width: 100%; + } + .polygon-review-controls { grid-template-columns: auto auto auto minmax(0, 1fr); } diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 91dad5f..939801d 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -1023,6 +1023,84 @@ padding: 0.15rem 0.1rem 0.25rem; } +.polygon-run-picker, +.dataset-result-select, +.dataset-sequence-select { + display: flex; + min-width: 0; + align-items: center; + gap: 0.75rem; + border-radius: 0.9rem; + background: var(--station-panel); + padding: 0.68rem 0.8rem; +} + +.polygon-run-picker > div, +.dataset-result-select > div { + display: grid; + flex: 0 0 auto; + gap: 0.18rem; +} + +.polygon-run-picker strong, +.dataset-result-select strong, +.dataset-sequence-select label { + color: var(--nodedc-text-primary); + font-size: 0.65rem; + font-weight: 650; +} + +.polygon-run-picker select, +.dataset-result-select select, +.dataset-sequence-select select { + min-width: 0; + flex: 1 1 auto; + border: 0; + border-radius: 999px; + outline: 0; + background: rgb(255 255 255 / 0.07); + color: var(--nodedc-text-secondary); + padding: 0.48rem 0.75rem; + font: inherit; + font-size: 0.6rem; +} + +.polygon-run-picker small, +.dataset-sequence-select > span { + flex: 0 0 auto; + color: var(--nodedc-text-muted); + font-size: 0.55rem; +} + +.polygon-run-input { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-radius: 1rem; + background: var(--station-panel); + padding: 0.9rem 1rem; +} + +.polygon-run-input h3, +.polygon-run-input p { + margin: 0; +} + +.polygon-run-input h3 { + margin-top: 0.28rem; + color: var(--nodedc-text-primary); + font-size: 0.94rem; +} + +.polygon-run-input p { + max-width: 48rem; + margin-top: 0.32rem; + color: var(--nodedc-text-muted); + font-size: 0.62rem; + line-height: 1.5; +} + .polygon-review-lead h2, .polygon-review-lead p { margin: 0; @@ -1064,6 +1142,91 @@ background: var(--station-panel); } +.dataset-sequence-review { + display: grid; + gap: 0.7rem; +} + +.dataset-sequence-review__truth { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(20rem, 0.6fr); + gap: 1rem; + border-radius: 1rem; + background: var(--station-panel); + padding: 0.95rem 1rem; +} + +.dataset-sequence-review__truth h2, +.dataset-sequence-review__truth p, +.dataset-sequence-review__truth dl { + margin: 0; +} + +.dataset-sequence-review__truth h2 { + margin-top: 0.3rem; + color: var(--nodedc-text-primary); + font-size: 1.05rem; + letter-spacing: -0.025em; +} + +.dataset-sequence-review__truth p { + max-width: 52rem; + margin-top: 0.38rem; + color: var(--nodedc-text-muted); + font-size: 0.63rem; + line-height: 1.55; +} + +.dataset-sequence-review__truth dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: stretch; + gap: 0.3rem; +} + +.dataset-sequence-review__truth dl > div { + display: grid; + align-content: center; + gap: 0.24rem; + border-radius: 0.75rem; + background: rgb(255 255 255 / 0.035); + padding: 0.65rem; +} + +.dataset-sequence-review__truth dt { + color: var(--nodedc-text-muted); + font-size: 0.52rem; +} + +.dataset-sequence-review__truth dd { + margin: 0; + color: var(--nodedc-text-primary); + font-size: 0.82rem; + font-weight: 670; +} + +.dataset-sequence-select { + border-radius: 0.9rem; +} + +.dataset-sequence-select label { + flex: 0 0 auto; +} + +.dataset-sequence-review__raw-note { + margin: 0; + border-radius: 0.9rem; + background: rgb(var(--nodedc-warning-rgb) / 0.08); + color: var(--nodedc-text-muted); + padding: 0.8rem 0.9rem; + font-size: 0.6rem; + line-height: 1.5; +} + +.dataset-sequence-timeline { + height: 2rem; +} + .polygon-review-player__header { display: flex; min-width: 0; diff --git a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx index 981d7e9..6309d10 100644 --- a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx +++ b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { Button, GlassSurface, @@ -7,16 +7,13 @@ import { import { fetchDatasetGatewayCatalog, - fetchDatasetGroundComparison, - fetchDatasetNativeScanPreview, type DatasetGatewayCatalog, - type DatasetGroundComparison, - type DatasetNativeScanPreview, } from "../core/lidar/datasetGateway"; import { - LidarGroundPointCloud, - type LidarGroundViewMode, -} from "./LidarGroundPointCloud"; + fetchPolygonRunCatalog, + type PolygonRunCatalog, +} from "../core/polygon/runArchive"; +import { GooseDatasetReview } from "./GooseDatasetReview"; const representationLabels: Record = { "native-scan": "Исходный скан", @@ -51,18 +48,22 @@ function formatBytes(value: number): string { }).format(value / 1_000_000_000); } +function isGooseQualificationRun( + item: PolygonRunCatalog["items"][number], +): boolean { + return item.kind === "replay_shadow" + && item.scenarioGeneration.startsWith("goose-3d"); +} + export function DatasetGatewayWorkspace() { const [catalog, setCatalog] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reloadGeneration, setReloadGeneration] = useState(0); const [previewOpen, setPreviewOpen] = useState(false); - const [preview, setPreview] = useState(null); - const [comparison, setComparison] = useState(null); - const [previewError, setPreviewError] = useState(null); - const [comparisonError, setComparisonError] = useState(null); - const [previewMode, setPreviewMode] = - useState("semantic"); + const [runCatalog, setRunCatalog] = useState(null); + const [selectedRunId, setSelectedRunId] = useState(null); + const [runError, setRunError] = useState(null); useEffect(() => { const controller = new AbortController(); @@ -93,64 +94,29 @@ export function DatasetGatewayWorkspace() { }, [catalog]); useEffect(() => { - if (!previewOpen || preview) return; const controller = new AbortController(); - setPreviewError(null); - void fetchDatasetNativeScanPreview({ signal: controller.signal }) + setRunError(null); + void fetchPolygonRunCatalog({ signal: controller.signal }) .then((value) => { - if (!controller.signal.aborted) setPreview(value); + if (controller.signal.aborted) return; + setRunCatalog(value); + const requested = new URLSearchParams(window.location.search).get("run"); + const gooseRuns = value.items.filter(isGooseQualificationRun); + const selected = gooseRuns.find((item) => item.runId === requested) + ?? gooseRuns[0] + ?? null; + setSelectedRunId(selected?.runId ?? null); }) .catch((loadError: unknown) => { - if (!controller.signal.aborted) setPreviewError(errorMessage(loadError)); + if (!controller.signal.aborted) setRunError(errorMessage(loadError)); }); return () => controller.abort(); - }, [previewOpen, preview]); - - useEffect(() => { - if (!previewOpen || comparison) return; - const controller = new AbortController(); - setComparisonError(null); - void fetchDatasetGroundComparison({ signal: controller.signal }) - .then((value) => { - if (!controller.signal.aborted) setComparison(value); - }) - .catch((loadError: unknown) => { - if (!controller.signal.aborted) { - setComparisonError(errorMessage(loadError)); - } - }); - return () => controller.abort(); - }, [comparison, previewOpen]); - - const previewFrame = useMemo(() => { - if (!preview) return null; - const emptyMask = new Array(preview.pointCount).fill(0); - const comparisonAligned = ( - comparison - && comparison.frameId === preview.frameId - && comparison.pointCount === preview.pointCount - ) ? comparison : null; - return { - pointCount: preview.pointCount, - pointsXyzM: preview.pointsXyzM, - intensity0To255: preview.remission0To255, - semanticRgb0To255: preview.semanticRgb0To255, - masks: { - currentGround: comparisonAligned?.currentGround ?? emptyMask, - currentAssigned: comparisonAligned?.evaluated ?? emptyMask, - candidateGround: comparisonAligned?.patchworkGround ?? emptyMask, - candidateAssigned: comparisonAligned?.patchworkAssigned ?? emptyMask, - disagreement: comparisonAligned?.currentDisagreement ?? emptyMask, - candidateDisagreement: - comparisonAligned?.patchworkDisagreement ?? emptyMask, - groundTruthGround: preview.groundTruthGround, - }, - }; - }, [comparison, preview]); + }, []); const downloadProgress = catalog?.source.archive ? catalog.source.archive.bytesTransferred / catalog.source.archive.totalBytes : null; + const gooseRuns = runCatalog?.items.filter(isGooseQualificationRun) ?? []; return (
@@ -213,9 +179,9 @@ export function DatasetGatewayWorkspace() { PUBLIC · OFF-ROAD · LABELED

{catalog.source.displayName}

- Один оборот VLS-128 с point-wise semantic и instance labels. - Первый baseline для ground segmentation и последующих - LiDAR-native моделей. + Validation split: восемь отдельных поездок и 961 разреженный + размеченный оборот VLS-128. Это коллекция native scans для + perception, а не непрерывное видео или траектория машины.

@@ -266,12 +232,12 @@ export function DatasetGatewayWorkspace() { disabled={catalog.source.admissionStatus !== "frame-ready"} title={ catalog.source.admissionStatus === "frame-ready" - ? "Открыть первый размеченный native scan" - : "Первый реальный кадр ещё не импортирован" + ? "Открыть все доступные последовательности и scans" + : "Размеченные scans ещё не импортированы" } onClick={() => setPreviewOpen((value) => !value)} > - {previewOpen ? "Закрыть" : "Открыть"} + {previewOpen ? "Закрыть источник" : "Открыть источник"} - ))} - - - {comparison ? ( -
-
-
Current · IoU
-
{(comparison.metrics.current.groundIou * 100).toFixed(1)}%
-
-
-
Current · Recall
-
{(comparison.metrics.current.recall * 100).toFixed(1)}%
-
-
-
Current · Natural
-
- {(comparison.metrics.current.naturalGroundRecall * 100).toFixed(1)}% -
-
-
-
Current · Latency
-
{comparison.latencyMs.current.toFixed(0)} ms
-
-
-
Patchwork++ · IoU
-
- {(comparison.metrics.patchworkpp.groundIou * 100).toFixed(1)}% -
-
-
-
Patchwork++ · Recall
-
{(comparison.metrics.patchworkpp.recall * 100).toFixed(1)}%
-
-
-
Patchwork++ · Natural
-
- {(comparison.metrics.patchworkpp.naturalGroundRecall * 100).toFixed(1)}% -
-
-
-
Patchwork++ · Latency
-
{comparison.latencyMs.patchworkpp.toFixed(1)} ms
-
-
- ) : comparisonError ? ( -

- Current baseline пока не рассчитан: {comparisonError} -

- ) : null} - {previewFrame ? ( - <> - -
- {previewMode === "semantic" ? ( - preview?.classes.map((item) => ( - - - {item.className} - - )) - ) : previewMode === "ground-truth" ? ( - <> - ground truth - остальные точки - - ) : previewMode === "current" ? ( - <> - current ground - current non-ground - - ) : previewMode === "candidate" ? ( - <> - Patchwork++ ground - Patchwork++ non-ground - - ) : previewMode === "disagreement" ? ( - <> - ошибка Current - совпадение с labels - - ) : previewMode === "candidate-disagreement" ? ( - <> - ошибка Patchwork++ - совпадение - - ) : ( - Remission нормализован только для визуального просмотра 0–255 - )} -
- - ) : ( -
- - {previewError ? "Preview недоступен" : "Читаем frame"} - -

{previewError ?? "Проверяем point alignment и разметку."}

-
- )} - {comparison ? ( -

- Patchwork++ использует опубликованную высоту VLS-128{" "} - {comparison.inputProfile.sensorHeightM.toFixed(2)} м; это - algorithm-specific admission, а не полный vehicle TF и не - normalized scan. -

) : null} + + + ) : previewOpen ? ( +
+ Review недоступен +

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

+

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

) : null} diff --git a/apps/control-station/src/workspaces/GooseDatasetReview.tsx b/apps/control-station/src/workspaces/GooseDatasetReview.tsx new file mode 100644 index 0000000..a630801 --- /dev/null +++ b/apps/control-station/src/workspaces/GooseDatasetReview.tsx @@ -0,0 +1,460 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { StatusBadge } from "@nodedc/ui-react"; + +import { + fetchGroundReview, + fetchGroundReviewFrame, + type GroundReview, + type GroundReviewFrame, + type GroundReviewFrameSummary, +} from "../core/polygon/groundQualification"; +import { + LidarGroundPointCloud, + type LidarGroundViewMode, +} from "./LidarGroundPointCloud"; + +interface GooseDatasetReviewProps { + runId: string; +} + +export interface GooseDatasetSequence { + datasetSequenceId: string; + frames: GroundReviewFrameSummary[]; + durationSeconds: number; + minimumGapSeconds: number; + maximumGapSeconds: number; +} + +const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [ + ["intensity", "Исходный скан"], + ["ground-truth", "Ground truth"], + ["current", "Current"], + ["candidate", "Patchwork++"], + ["disagreement", "Ошибки Current"], + ["candidate-disagreement", "Ошибки Patchwork++"], +]; + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) return error.message; + return "Не удалось открыть последовательности GOOSE."; +} + +function secondsBetween( + left: GroundReviewFrameSummary, + right: GroundReviewFrameSummary, +): number { + const delta = BigInt(right.sensorTimestampNs) - BigInt(left.sensorTimestampNs); + return Number(delta) / 1_000_000_000; +} + +export function groupGooseReviewFrames( + review: GroundReview, +): GooseDatasetSequence[] { + const groups = new Map(); + review.frames.forEach((frame) => { + const frames = groups.get(frame.datasetSequenceId) ?? []; + frames.push(frame); + groups.set(frame.datasetSequenceId, frames); + }); + return [...groups.entries()].map(([datasetSequenceId, frames]) => { + const gaps = frames.slice(1).map((frame, index) => ( + secondsBetween(frames[index], frame) + )); + return { + datasetSequenceId, + frames, + durationSeconds: frames.length > 1 + ? secondsBetween(frames[0], frames[frames.length - 1]) + : 0, + minimumGapSeconds: gaps.length ? Math.min(...gaps) : 0, + maximumGapSeconds: gaps.length ? Math.max(...gaps) : 0, + }; + }); +} + +function sequenceLabel(sequenceId: string): string { + const date = sequenceId.slice(0, 10); + const title = sequenceId.slice(11).replaceAll("_", " "); + return `${date} · ${title}`; +} + +function formatDuration(seconds: number): string { + const rounded = Math.max(0, Math.round(seconds)); + const minutes = Math.floor(rounded / 60); + const remainder = rounded % 60; + return `${minutes}:${String(remainder).padStart(2, "0")}`; +} + +function formatGap(seconds: number): string { + return `${seconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с`; +} + +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 frameScene(frame: GroundReviewFrame | null) { + if (!frame) return null; + return { + pointCount: frame.pointCount, + pointsXyzM: frame.pointsXyzM, + intensity0To255: frame.intensity0To255, + masks: { + currentGround: frame.currentGround, + currentAssigned: frame.evaluated, + candidateGround: frame.patchworkGround, + candidateAssigned: frame.evaluated, + disagreement: frame.currentDisagreement, + candidateDisagreement: frame.patchworkDisagreement, + groundTruthGround: frame.groundTruthGround, + }, + }; +} + +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 === "candidate") return "Результат Patchwork++ для этого исходного скана."; + if (mode === "disagreement") return "Красным отмечены ошибки Current относительно labels."; + return "Красным отмечены ошибки Patchwork++ относительно labels."; +} + +export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) { + const [review, setReview] = useState(null); + const [error, setError] = useState(null); + const [selectedSequenceId, setSelectedSequenceId] = useState(null); + const [selectedFrameId, setSelectedFrameId] = useState(null); + const [reviewFrame, setReviewFrame] = useState(null); + const [mode, setMode] = useState("intensity"); + const [playing, setPlaying] = useState(false); + const [frameLoading, setFrameLoading] = useState(false); + const frameCache = useRef(new Map()); + + useEffect(() => { + const controller = new AbortController(); + setError(null); + setReview(null); + setReviewFrame(null); + setPlaying(false); + frameCache.current.clear(); + void fetchGroundReview(runId, { signal: controller.signal }) + .then((value) => { + if (controller.signal.aborted || !value) return; + setReview(value); + const first = value.frames[0] ?? null; + setSelectedSequenceId(first?.datasetSequenceId ?? null); + setSelectedFrameId(first?.frameId ?? null); + }) + .catch((loadError: unknown) => { + if (!controller.signal.aborted) setError(errorMessage(loadError)); + }); + return () => controller.abort(); + }, [runId]); + + const sequences = useMemo( + () => review ? groupGooseReviewFrames(review) : [], + [review], + ); + const selectedSequence = sequences.find( + (sequence) => sequence.datasetSequenceId === selectedSequenceId, + ) ?? sequences[0] ?? null; + const selectedPosition = Math.max( + 0, + selectedSequence?.frames.findIndex((frame) => frame.frameId === selectedFrameId) ?? 0, + ); + const selectedSummary = selectedSequence?.frames[selectedPosition] ?? null; + const previousSummary = selectedSequence && selectedPosition > 0 + ? selectedSequence.frames[selectedPosition - 1] + : null; + const gapFromPrevious = selectedSummary && previousSummary + ? secondsBetween(previousSummary, selectedSummary) + : null; + + useEffect(() => { + if (!selectedFrameId) return; + const cached = frameCache.current.get(selectedFrameId); + if (cached) { + setReviewFrame(cached); + setFrameLoading(false); + return; + } + const controller = new AbortController(); + setFrameLoading(true); + void fetchGroundReviewFrame(runId, selectedFrameId, { + signal: controller.signal, + }) + .then((frame) => { + if (controller.signal.aborted) return; + frameCache.current.set(frame.frameId, frame); + while (frameCache.current.size > 32) { + const oldest = frameCache.current.keys().next().value as string | undefined; + if (oldest) frameCache.current.delete(oldest); + else break; + } + setReviewFrame(frame); + setFrameLoading(false); + }) + .catch((loadError: unknown) => { + if (controller.signal.aborted) return; + setFrameLoading(false); + setError(errorMessage(loadError)); + }); + return () => controller.abort(); + }, [runId, selectedFrameId]); + + useEffect(() => { + if ( + !playing + || !selectedSequence + || !selectedFrameId + || reviewFrame?.frameId !== selectedFrameId + ) return; + const timer = window.setTimeout(() => { + const next = selectedSequence.frames[selectedPosition + 1]; + if (!next) { + setPlaying(false); + return; + } + setSelectedFrameId(next.frameId); + }, 700); + return () => window.clearTimeout(timer); + }, [ + playing, + reviewFrame?.frameId, + selectedFrameId, + selectedPosition, + selectedSequence, + ]); + + const scene = useMemo(() => frameScene(reviewFrame), [reviewFrame]); + + const selectPosition = (position: number) => { + if (!selectedSequence) return; + const bounded = Math.max(0, Math.min(position, selectedSequence.frames.length - 1)); + setPlaying(false); + setSelectedFrameId(selectedSequence.frames[bounded]?.frameId ?? null); + }; + + const selectSequence = (sequenceId: string) => { + const sequence = sequences.find((candidate) => ( + candidate.datasetSequenceId === sequenceId + )); + setPlaying(false); + setSelectedSequenceId(sequenceId); + setSelectedFrameId(sequence?.frames[0]?.frameId ?? null); + setReviewFrame(null); + }; + + if (error) { + return ( +
+ Источник недоступен +

Не удалось открыть GOOSE

+

{error}

+
+ ); + } + + if (!review || !selectedSequence) { + return ( +
+ Читаем индекс +

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

+

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

+
+ ); + } + + return ( +
+
+
+ СТРУКТУРА ИСХОДНИКА +

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

+

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

+
+
+
Последовательностей
{sequences.length}
+
Размеченных сканов
{review.frameCount}
+
Preview
{review.previewPoints.toLocaleString("ru-RU")} точек
+
+
+ +
+ + + + {selectedSequence.frames.length} размеченных сканов · интервал{" "} + {formatGap(selectedSequence.minimumGapSeconds)}–{ + formatGap(selectedSequence.maximumGapSeconds) + } · охват {formatDuration(selectedSequence.durationSeconds)} + +
+ +
+
+
+ + SCAN {selectedPosition + 1} / {selectedSequence.frames.length} + {" · SOURCE FRAME "}{selectedSummary?.datasetFrameNumber ?? "—"} + + {selectedSummary?.frameId ?? "Загружаем скан"} +
+
+ {viewModes.map(([viewMode, label]) => ( + + ))} +
+
+ +
+ + + + selectPosition(Number(event.target.value))} + /> + {selectedPosition + 1} / {selectedSequence.frames.length} +
+ +
+ {scene ? ( + + ) : ( +
Загружаем скан…
+ )} +
+ {viewModes.find(([viewMode]) => viewMode === mode)?.[1]} + {modeExplanation(mode)} + {frameLoading ? Подгружаем выбранный scan… : null} +
+
+ + {selectedSummary ? ( +
+
+ Интервал от предыдущего + {gapFromPrevious === null ? "Начало" : formatGap(gapFromPrevious)} + реальный timestamp, не cadence playback +
+
+ Current · Ground IoU + {formatPercent(selectedSummary.current.groundIou)} + overlay выбранного прогона +
+
+ Patchwork++ · Ground IoU + {formatPercent(selectedSummary.patchwork.groundIou)} + overlay выбранного прогона +
+
= 0 ? "true" : "false"}> + Разница + {formatDelta(selectedSummary.groundIouDelta)} + Patchwork++ минус Current +
+
+ ) : null} + +
+ {selectedSequence.frames.map((frame, index) => ( +
+

+ Риски показывают только доступные размеченные scans этой поездки. + Пропуски времени не интерполируются; ракурс сохраняется между соседними scans. +

+
+ +

+ Для настоящего движения машины, odometry и синхронного sensor playback нужен + отдельный raw ROS bag GOOSE с localization. Он не входит в принятый 3.3 ГБ + annotated validation ZIP и сейчас не притворяется доступным. +

+
+ ); +} diff --git a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx index 5cb0a81..bc78387 100644 --- a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx +++ b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx @@ -30,6 +30,8 @@ export interface LidarGroundPointCloudFrame { interface LidarGroundPointCloudProps { frame: LidarGroundPointCloudFrame; mode: LidarGroundViewMode; + coordinateFrame?: "auto-fit" | "sensor-fixed"; + sensorHeightM?: number; } function percentile(values: number[], fraction: number): number { @@ -140,6 +142,8 @@ function frameColors( export function LidarGroundPointCloud({ frame, mode, + coordinateFrame = "auto-fit", + sensorHeightM = 0, }: LidarGroundPointCloudProps) { const hostRef = useRef(null); const geometryRef = useRef(null); @@ -149,6 +153,8 @@ export function LidarGroundPointCloud({ const fogRef = useRef(null); const gridRef = useRef(null); const viewRadiusRef = useRef(1); + const viewTargetHeightRef = useRef(0.25); + const viewInitializedRef = useRef(false); const [renderError, setRenderError] = useState(null); useEffect(() => { @@ -265,6 +271,8 @@ export function LidarGroundPointCloud({ fogRef.current = null; gridRef.current = null; viewRadiusRef.current = 1; + viewTargetHeightRef.current = 0.25; + viewInitializedRef.current = false; }; }, []); @@ -294,12 +302,18 @@ export function LidarGroundPointCloud({ const maximumY = percentile(sampleY, 0.99); const minimumZ = percentile(sampleZ, 0.01); const maximumZ = percentile(sampleZ, 0.99); - const centerX = (minimumX + maximumX) / 2; - const centerY = (minimumY + maximumY) / 2; + const centerX = coordinateFrame === "sensor-fixed" + ? 0 + : (minimumX + maximumX) / 2; + const centerY = coordinateFrame === "sensor-fixed" + ? 0 + : (minimumY + maximumY) / 2; frame.pointsXyzM.forEach(([x, y, z], index) => { const offset = index * 3; positions[offset] = x - centerX; - positions[offset + 1] = z - minimumZ; + positions[offset + 1] = coordinateFrame === "sensor-fixed" + ? z + sensorHeightM + : z - minimumZ; positions[offset + 2] = -(y - centerY); }); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); @@ -317,15 +331,21 @@ export function LidarGroundPointCloud({ fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035); grid.scale.setScalar(Math.max(radius / 5, 0.25)); - const targetHeight = Math.max((maximumZ - minimumZ) * 0.42, 0.15); + const targetHeight = coordinateFrame === "sensor-fixed" + ? Math.max(sensorHeightM * 0.35, 0.35) + : Math.max((maximumZ - minimumZ) * 0.42, 0.15); const distance = Math.max(radius * 1.15, 0.9); - controls.target.set(0, targetHeight, 0); - camera.position.set(distance, distance * 0.72, distance); + viewTargetHeightRef.current = targetHeight; camera.near = Math.max(distance / 1_000, 0.005); camera.far = Math.max(distance * 100, 100); camera.updateProjectionMatrix(); + if (coordinateFrame === "auto-fit" || !viewInitializedRef.current) { + controls.target.set(0, targetHeight, 0); + camera.position.set(distance, distance * 0.72, distance); + viewInitializedRef.current = true; + } controls.update(); - }, [frame]); + }, [coordinateFrame, frame, sensorHeightM]); useEffect(() => { const geometry = geometryRef.current; @@ -345,7 +365,7 @@ export function LidarGroundPointCloud({ const radius = Math.max(viewRadiusRef.current, 0.2); const distance = Math.max(radius * 1.15, 0.9); camera.position.set(distance, distance * 0.72, distance); - controls.target.set(0, radius * 0.25, 0); + controls.target.set(0, viewTargetHeightRef.current, 0); controls.update(); }; diff --git a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx index 0d74277..b4589ed 100644 --- a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx +++ b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx @@ -1,20 +1,9 @@ -import { - useEffect, - useMemo, - useRef, - useState, - type CSSProperties, -} from "react"; +import { useEffect, useState } from "react"; import { Button, StatusBadge } from "@nodedc/ui-react"; import { fetchGroundQualification, - fetchGroundReview, - fetchGroundReviewFrame, type GroundQualification, - type GroundReview, - type GroundReviewFrame, - type GroundReviewFrameSummary, } from "../core/polygon/groundQualification"; import { fetchPolygonRunCatalog, @@ -24,17 +13,11 @@ import { type PolygonRunRoute, type PolygonRunState, } from "../core/polygon/runArchive"; -import { - LidarGroundPointCloud, - type LidarGroundViewMode, -} from "./LidarGroundPointCloud"; interface PolygonRunWorkspaceProps { route: PolygonRunRoute; } -type ReviewOrder = "sequence" | "best" | "worst"; - const stateLabels: Record = { admitted: "Допущен", starting: "Запускается", @@ -46,21 +29,6 @@ const stateLabels: Record = { aborted: "Прерван", }; -const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [ - ["intensity", "Облако"], - ["ground-truth", "Эталон"], - ["current", "Current"], - ["candidate", "Patchwork++"], - ["disagreement", "Ошибки Current"], - ["candidate-disagreement", "Ошибки Patchwork++"], -]; - -const orderLabels: ReadonlyArray<[ReviewOrder, string]> = [ - ["sequence", "По записи"], - ["best", "Лучшие"], - ["worst", "Худшие"], -]; - function stateTone( state: PolygonRunState, ): "success" | "accent" | "warning" | "danger" | "neutral" { @@ -71,6 +39,17 @@ function stateTone( 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", { @@ -88,11 +67,6 @@ function formatBytes(value: number): string { })} ${units[exponent]}`; } -function errorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim()) return error.message; - return "Не удалось прочитать прогон."; -} - function formatPercent(value: number): string { return new Intl.NumberFormat("ru-RU", { style: "percent", @@ -107,74 +81,19 @@ function formatDelta(value: number): string { })} п.п.`; } -function formatMilliseconds(value: number): string { - return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`; -} - -function frameScene(frame: GroundReviewFrame | null) { - if (!frame) return null; - return { - pointCount: frame.pointCount, - pointsXyzM: frame.pointsXyzM, - intensity0To255: frame.intensity0To255, - masks: { - currentGround: frame.currentGround, - currentAssigned: frame.evaluated, - candidateGround: frame.patchworkGround, - candidateAssigned: frame.evaluated, - disagreement: frame.currentDisagreement, - candidateDisagreement: frame.patchworkDisagreement, - groundTruthGround: frame.groundTruthGround, - }, - }; -} - -function modeExplanation(mode: LidarGroundViewMode): string { - if (mode === "intensity") return "Нейтральная геометрия нативного LiDAR-кадра."; - if (mode === "ground-truth") return "Публичная GOOSE-разметка: земля и всё остальное."; - if (mode === "current") return "Что текущий алгоритм Mission Core считает землёй."; - if (mode === "candidate") return "Что Patchwork++ считает землёй."; - if (mode === "disagreement") return "Красным — точки, где Current расходится с эталоном."; - return "Красным — точки, где Patchwork++ расходится с эталоном."; -} - -function frameColor(delta: number): string { - if (delta < 0) { - const opacity = Math.min(0.92, 0.42 + Math.abs(delta) * 3); - return `rgb(255 104 104 / ${opacity})`; - } - const opacity = Math.min(0.9, 0.25 + delta * 2.8); - return `rgb(210 242 188 / ${opacity})`; -} - -function orderFrames( - review: GroundReview, - order: ReviewOrder, -): GroundReviewFrameSummary[] { - if (order === "sequence") return review.frames; - return [...review.frames].sort((left, right) => ( - order === "best" - ? right.groundIouDelta - left.groundIouDelta - : left.groundIouDelta - right.groundIouDelta - )); +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); - const [qualification, setQualification] = useState(null); - const [review, setReview] = useState(null); - const [reviewError, setReviewError] = useState(null); - const [selectedFrameId, setSelectedFrameId] = useState(null); - const [reviewFrame, setReviewFrame] = useState(null); - const [reviewMode, setReviewMode] = useState("ground-truth"); - const [reviewOrder, setReviewOrder] = useState("sequence"); - const [playing, setPlaying] = useState(false); - const frameCache = useRef(new Map()); useEffect(() => { if (route.error) { @@ -190,20 +109,30 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal }); if (controller.signal.aborted) return; setCatalog(nextCatalog); - const targetRunId = selectedRunId ?? route.runId ?? nextCatalog.items[0]?.runId ?? null; + 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); @@ -212,135 +141,13 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { return () => controller.abort(); }, [reloadGeneration, route.error, route.runId, selectedRunId]); - useEffect(() => { - const runId = detail?.run.runId; - setQualification(null); - setReview(null); - setReviewFrame(null); - setSelectedFrameId(null); - setReviewError(null); - setPlaying(false); - frameCache.current.clear(); - if (!runId) return; - const controller = new AbortController(); - void (async () => { - try { - const nextQualification = await fetchGroundQualification(runId, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - setQualification(nextQualification); - if (!nextQualification) return; - const nextReview = await fetchGroundReview(runId, { - signal: controller.signal, - }); - if (controller.signal.aborted) return; - setReview(nextReview); - setSelectedFrameId(nextReview?.frames[0]?.frameId ?? null); - } catch (loadError) { - if (!controller.signal.aborted) setReviewError(errorMessage(loadError)); - } - })(); - return () => controller.abort(); - }, [detail?.run.runId]); - - useEffect(() => { - const runId = review?.runId; - if (!runId || !selectedFrameId) { - setReviewFrame(null); - return; - } - const cached = frameCache.current.get(selectedFrameId); - if (cached) { - setReviewFrame(cached); - return; - } - const controller = new AbortController(); - setReviewFrame(null); - void fetchGroundReviewFrame(runId, selectedFrameId, { - signal: controller.signal, - }) - .then((frame) => { - if (controller.signal.aborted) return; - frameCache.current.set(frame.frameId, frame); - while (frameCache.current.size > 24) { - const oldest = frameCache.current.keys().next().value as string | undefined; - if (oldest) frameCache.current.delete(oldest); - else break; - } - setReviewFrame(frame); - }) - .catch((loadError: unknown) => { - if (!controller.signal.aborted) setReviewError(errorMessage(loadError)); - }); - return () => controller.abort(); - }, [review?.runId, selectedFrameId]); - - const orderedFrames = useMemo( - () => review ? orderFrames(review, reviewOrder) : [], - [review, reviewOrder], - ); - const selectedPosition = Math.max( - 0, - orderedFrames.findIndex((frame) => frame.frameId === selectedFrameId), - ); - const selectedSummary = orderedFrames[selectedPosition] ?? null; - const bestFrame = useMemo( - () => review - ? [...review.frames].sort( - (left, right) => right.groundIouDelta - left.groundIouDelta, - )[0] - : null, - [review], - ); - const worstFrame = useMemo( - () => review - ? [...review.frames].sort( - (left, right) => left.groundIouDelta - right.groundIouDelta, - )[0] - : null, - [review], - ); - - useEffect(() => { - if (!playing || !reviewFrame || !selectedFrameId || !orderedFrames.length) return; - if (reviewFrame.frameId !== selectedFrameId) return; - const timer = window.setTimeout(() => { - const nextPosition = selectedPosition + 1; - if (nextPosition >= orderedFrames.length) { - setPlaying(false); - return; - } - setSelectedFrameId(orderedFrames[nextPosition].frameId); - }, 520); - return () => window.clearTimeout(timer); - }, [ - orderedFrames, - playing, - reviewFrame, - selectedFrameId, - selectedPosition, - ]); - - const scene = useMemo(() => frameScene(reviewFrame), [reviewFrame]); - - const selectPosition = (position: number) => { - const frame = orderedFrames[Math.max(0, Math.min(position, orderedFrames.length - 1))]; - if (frame) setSelectedFrameId(frame.frameId); - }; - - const changeOrder = (order: ReviewOrder) => { - setPlaying(false); - setReviewOrder(order); - }; - if (loading && !detail) { return (
ПОЛИГОН / ПРОГОНЫ -

Открываем запись

-

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

+

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

+

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

); @@ -370,8 +177,8 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
ПОЛИГОН / ПРОГОНЫ -

Записей пока нет

-

Здесь появятся replay- и simulation-прогоны после публикации результатов.

+

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

+

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

); @@ -380,195 +187,75 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const { run } = detail; return (
+
+
+ ИСТОРИЯ ПОЛИГОНА + Выбранный прогон +
+ + {catalog?.total ?? 0} в архиве +
+
- ПОЛИГОН / ЗАПИСЬ ДАТАСЕТА + {runKindLabel(run.kind).toUpperCase()}

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

{qualification - ? `${qualification.frameCount} кадров · Current и Patchwork++ против публичной разметки` - : "Архивный прогон без покадрового perception review."} + ? "Результат алгоритмического replay. Сам исходный датасет и его sequences открываются в разделе «Датасеты»." + : "Воспроизводимая запись запуска Полигона."}

-
- {stateLabels[run.state]} - {catalog && catalog.items.length > 1 ? ( - - ) : null} -
+ {stateLabels[run.state]}
- {qualification && review ? ( + {qualification ? ( <> -
-
-
- КАДР {selectedSummary - ? selectedSummary.sequence + 1 - : "—"} / {review.frameCount} - {selectedSummary?.frameId ?? "Загружаем запись"} -
-
- {viewModes.map(([mode, label]) => ( - - ))} -
-
- -
- - - - selectPosition(Number(event.target.value))} - /> - {selectedPosition + 1} / {orderedFrames.length} +
+
+ ВХОД ПРОГОНА +

GOOSE 3D · validation

+

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

- -
- {scene ? ( - - ) : ( -
Загружаем кадр…
+
- - {selectedSummary ? ( -
-
- Current · Ground IoU - {formatPercent(selectedSummary.current.groundIou)} - прогноз против эталона -
-
- Patchwork++ · Ground IoU - {formatPercent(selectedSummary.patchwork.groundIou)} - прогноз против эталона -
-
= 0 ? "true" : "false"}> - Разница на этом кадре - {formatDelta(selectedSummary.groundIouDelta)} - Patchwork++ минус Current -
-
- Natural ground · PW++ - {formatPercent( - selectedSummary.patchwork.naturalGroundRecall, - )} - recall на естественном грунте -
-
- ) : null} - -
-
- {orderLabels.map(([order, label]) => ( - - ))} -
-
- - -
-
- -
- {orderedFrames.map((frame, index) => ( -
-

- Каждая риска — доступный кадр. Светлая означает улучшение IoU, красная — - ухудшение. Можно нажать на любую и проверить результат вручную. -

+ Открыть исходник +
- ВЕСЬ VALIDATION SPLIT -

Что именно сравнивалось

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

Current против Patchwork++

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

- На каждом из {qualification.frameCount} нативных LiDAR-кадров оба алгоритма - независимо решали одну задачу: какие точки являются землёй. Их ответы - сравнивались с публичной point-aligned разметкой GOOSE. + Оба алгоритма независимо обработали одни и те же 961 scans. + Покадровая визуальная проверка теперь находится рядом с самим входом + в «Датасетах», а не притворяется симуляцией машины.

@@ -597,16 +284,20 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
Latency p95 - {formatMilliseconds(qualification.current.latencyMs.p95)} + {qualification.current.latencyMs.p95.toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })} мс - {formatMilliseconds(qualification.patchwork.latencyMs.p95)} - на кадр + {qualification.patchwork.latencyMs.p95.toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })} мс + на scan
- Полные метрики и деградации + Acceptance и деградации
ACCEPTANCE GATES @@ -629,28 +320,20 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { {profileId} IoU {formatPercent(aggregate.micro.groundIou)} Natural {formatPercent(aggregate.micro.naturalGroundRecall)} - p95 {formatMilliseconds(aggregate.latencyMs.p95)} + p95 {aggregate.latencyMs.p95.toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })} мс

))}
- ) : qualification ? ( -
- ПОКАДРОВЫЙ ПРОСМОТР -

Review-pack ещё не опубликован

-

- Числовой отчёт есть, но мы не показываем его как достаточный результат: - оператор должен иметь возможность проверить все {qualification.frameCount} кадров. -

- {reviewError ? {reviewError} : null} -
) : (
АРХИВНЫЙ ПРОГОН -

В этой записи нет perception review

-

Технические доказательства доступны в раскрытии ниже.

+

Для этого запуска нет perception qualification

+

Техническая идентичность доступна ниже.

)} @@ -659,6 +342,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
Run ID
{run.runId}
+
Тип
{runKindLabel(run.kind)}
Сценарий
{run.scenarioGeneration}
Профиль
{run.profileGeneration}
Mission Core
{run.missionCoreCommit.slice(0, 12)}
diff --git a/apps/control-station/test/groundQualification.test.mjs b/apps/control-station/test/groundQualification.test.mjs index 6a09ba2..e927f08 100644 --- a/apps/control-station/test/groundQualification.test.mjs +++ b/apps/control-station/test/groundQualification.test.mjs @@ -10,6 +10,8 @@ let decodeGroundReview; let decodeGroundReviewFrame; let fetchGroundQualification; let GroundQualificationContractError; +let parseGooseFrameId; +let groupGooseReviewFrames; const aggregate = { micro: { @@ -76,7 +78,11 @@ before(async () => { decodeGroundReviewFrame, fetchGroundQualification, GroundQualificationContractError, + parseGooseFrameId, } = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts")); + ({ + groupGooseReviewFrames, + } = await server.ssrLoadModule("/src/workspaces/GooseDatasetReview.tsx")); }); after(async () => { @@ -139,7 +145,7 @@ test("treats missing qualification as a generic run", async () => { test("decodes a complete ordered frame review", () => { const decoded = decodeGroundReview({ - schema_version: "missioncore.polygon-ground-review/v1", + schema_version: "missioncore.polygon-ground-review/v2", access: "read-only", run_id: "goose-ground-abc", identity_sha256: "b".repeat(64), @@ -148,7 +154,10 @@ test("decodes a complete ordered frame review", () => { preview_points: 12_000, frames: [0, 1].map((sequence) => ({ sequence, - frame_id: `2022-07-22_flight__0071_000${sequence}`, + frame_id: `2022-07-22_flight__007${sequence + 1}_165849423${sequence}334310308`, + dataset_sequence_id: "2022-07-22_flight", + dataset_frame_number: sequence + 71, + sensor_timestamp_ns: `165849423${sequence}334310308`, source_point_count: 100_000, point_count: 12_000, current: { @@ -168,9 +177,59 @@ test("decodes a complete ordered frame review", () => { }); assert.equal(decoded.frames.length, 2); assert.equal(decoded.frames[1].sequence, 1); + assert.equal(decoded.frames[1].datasetSequenceId, "2022-07-22_flight"); + assert.equal(decoded.frames[1].datasetFrameNumber, 72); assert.equal(decoded.frames[0].patchwork.groundIou, 0.7); }); +test("parses GOOSE sequence, source frame and nanosecond timestamp", () => { + assert.deepEqual( + parseGooseFrameId("2023-05-17_neubiberg_sunny__0459_1684330012686564560"), + { + datasetSequenceId: "2023-05-17_neubiberg_sunny", + datasetFrameNumber: 459, + sensorTimestampNs: "1684330012686564560", + }, + ); +}); + +test("keeps independent GOOSE recordings in separate playback sequences", () => { + const metric = { + groundIou: 0.5, + naturalGroundRecall: 0.6, + obstacleNonGroundRecall: 0.9, + latencyMs: 20, + }; + const frames = [ + ["2022-07-22_flight", 71, "1658494234334310308"], + ["2022-07-22_flight", 72, "1658494235988100385"], + ["2023-05-17_neubiberg_sunny", 459, "1684330012686564560"], + ].map(([datasetSequenceId, datasetFrameNumber, sensorTimestampNs], sequence) => ({ + sequence, + frameId: `${datasetSequenceId}__${String(datasetFrameNumber).padStart(4, "0")}_${sensorTimestampNs}`, + datasetSequenceId, + datasetFrameNumber, + sensorTimestampNs, + sourcePointCount: 100_000, + pointCount: 12_000, + current: metric, + patchwork: metric, + groundIouDelta: 0, + })); + const groups = groupGooseReviewFrames({ + runId: "goose-ground-abc", + identitySha256: "b".repeat(64), + sourceId: "goose-3d/v2025-08-22", + frameCount: frames.length, + previewPoints: 12_000, + frames, + }); + assert.equal(groups.length, 2); + assert.equal(groups[0].frames.length, 2); + assert.equal(groups[1].frames.length, 1); + assert.ok(groups[0].durationSeconds > 1.6); +}); + test("decodes a point-aligned review frame with intensity", () => { const decoded = decodeGroundReviewFrame({ schema_version: "missioncore.polygon-ground-review-frame/v1", diff --git a/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md b/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md index 296e546..4930ed8 100644 --- a/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md +++ b/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md @@ -847,10 +847,11 @@ show an explicit offline state. The Polygon navigation is task-oriented: -- `Прогоны` owns recorded replay/simulation history and operator review of - qualification evidence; -- `Датасеты` owns admitted replay inputs and the entry point for a future - qualification run. +- `Прогоны` owns selectable recorded simulation/dataset-replay/shadow history, + aggregate outcomes, provenance and decisions; +- `Датасеты` owns admitted replay inputs, their real source structure and + operator inspection of source scans. A selected run may be overlaid on the + source, but the source does not move into the run archive. Dataset bytes and source artifacts remain in Data storage, but algorithm evaluation does not live under `Данные`. Device-specific scanner diagnostics @@ -864,21 +865,30 @@ They require a separate live surface that appears only when the Simulation Worker capability is available. The current `Прогоны` implementation is read-only and remains fully usable while that worker is offline. -### UI-0 — early read-only run view +### UI-0 — read-only run history and dataset inspection Target S1B proves persisted start/health/stop history. The first direct developer-oriented view exposed lifecycle, events, provider pins and artifact -hashes on the main screen. Operator review rejected that composition. The -current UI-0 makes the recorded evidence primary: +hashes on the main screen. Operator review rejected that composition and then +rejected treating the GOOSE validation split as a single recording. The +current UI-0 follows two separate operator tasks: -- full source-order frame index instead of five selected examples; -- Play/pause, previous/next and arbitrary scrub; -- direct ground-truth, Current, Patchwork++ and error-map modes; -- current-frame metrics and a clickable all-frame improvement/regression strip; -- automatic best/worst ordering without preventing arbitrary manual selection; -- aggregate comparison after, rather than before, visual evidence; -- acceptance/degradation and run identity/provider/artifact details collapsed - below the operator workflow. +- `Прогоны` always shows a run selector, even when the archive currently has + only one item. The selected run exposes its kind (`simulation`, + `dataset-replay` or `shadow`), input identity, aggregate result and collapsed + technical evidence. +- `Датасеты → Открыть источник` shows the admitted source. GOOSE validation is + explicitly grouped into eight independent sequences; Play is bounded to one + sequence and described as accelerated review of sparse annotated scans. +- The dataset viewer exposes source frame number, nanosecond timestamp gaps, + ground truth and optional Current/Patchwork++ overlays from a selected run. +- The fixed sensor-centric coordinate frame and operator camera are preserved + within a sequence. Missing scans, ego trajectory and cross-sequence motion + are never synthesized. + +The accepted `3.3 GB` annotated validation ZIP is not a continuous recording. +Continuous ego-motion requires a separately admitted raw ROS bag and +localization source. The backend reads the configured repository from `MISSIONCORE_POLYGON_RUNS_ROOT` using diff --git a/docs/14_LIDAR_DATASET_GATEWAY.md b/docs/14_LIDAR_DATASET_GATEWAY.md index e98ae84..dbcbc81 100644 --- a/docs/14_LIDAR_DATASET_GATEWAY.md +++ b/docs/14_LIDAR_DATASET_GATEWAY.md @@ -65,9 +65,12 @@ The gateway is not part of device quality diagnostics. dataset frames into the selected device evidence. - **Наблюдение** opens a concrete live or recorded spatial scene. - **Данные** owns source-of-record, retention, replay preparation and export. -- **Полигон → Датасеты** lists admitted evaluation inputs. +- **Полигон → Датасеты** lists admitted evaluation inputs and owns source + inspection: source sequences, sparse annotated scans and optional overlays + from a selected qualification run. - **Полигон → Прогоны** owns the resulting algorithm comparison, metrics, - provenance and decision. + provenance and decision. It does not present the dataset itself as a + simulation or continuous vehicle recording. Before real dataset bytes exist, the catalog explains the blocked storage gate and keeps `Открыть` / `Создать прогон` disabled. `Открыть` becomes available @@ -341,9 +344,37 @@ qualification report: - deterministic `12,000`-point frame previews with remission, public point-aligned ground truth, Current and Patchwork++ masks; - compact size `67 MB`, versus multiple gigabytes for equivalent JSON; -- `Play`, frame scrubber, a clickable all-frame delta strip, best/worst - ordering and direct modes for geometry, ground truth, both providers and - both error maps. +- direct modes for geometry, ground truth, both providers and both error maps. + +The subsequent product review found a second semantic error: the `961` frames +were presented as one playable recording. They are actually `8` independent +GOOSE validation sequences: + +- `2022-07-22_flight` — `151` annotated scans; +- `2022-08-30_siegertsbrunn_feldwege` — `103`; +- `2022-09-21_garching_uebungsplatz_2` — `123`; +- `2022-12-07_aying_hills` — `133`; +- `2023-01-20_aying_mangfall_2` — `191`; +- `2023-03-03_garching_2` — `106`; +- `2023-05-15_neubiberg_rain` — `73`; +- `2023-05-17_neubiberg_sunny` — `81`. + +Each admitted `.bin` is one annotated VLS-128 revolution. Within those eight +sequences the validation scans are sparse: observed adjacent timestamps range +from about `0.1 s` to `167.5 s`. The annotated ZIP contains neither a +continuous playback stream nor a pose chain that could support visually +continuous ego-motion. Mission Core therefore: + +- never plays across a sequence boundary; +- labels Play as an accelerated scan review, not real-time playback; +- exposes the exact source frame number and timestamp gap; +- keeps a fixed sensor-centric origin and preserves the operator camera within + one sequence instead of refitting every frame; +- does not interpolate missing scans or invent a route. + +Continuous vehicle motion, odometry and synchronized sensor playback require +the separately admitted raw GOOSE ROS bags plus localization. Those assets are +not part of the current `3.3 GB` annotated validation archive. The source archive and full-resolution crash-resume cache remain only on worker D. A bounded review mirror may be served by a Mission Core backend, but SSH is diff --git a/src/k1link/web/polygon_api.py b/src/k1link/web/polygon_api.py index 7553fbf..9da82c8 100644 --- a/src/k1link/web/polygon_api.py +++ b/src/k1link/web/polygon_api.py @@ -44,7 +44,7 @@ GROUND_REPORT_ARTIFACT_KIND: Final = "goose-ground-qualification-report" GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview" GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1" GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1" -GROUND_REVIEW_SCHEMA: Final = "missioncore.polygon-ground-review/v1" +GROUND_REVIEW_SCHEMA: Final = "missioncore.polygon-ground-review/v2" GROUND_REVIEW_FRAME_SCHEMA: Final = "missioncore.polygon-ground-review-frame/v1" GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1" GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/v1" @@ -54,6 +54,11 @@ MAX_REVIEW_FRAME_BYTES: Final = 2 * 1024**2 MAX_REVIEW_FRAMES: Final = 2_000 MAX_REVIEW_POINTS: Final = 20_000 COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$") +GOOSE_FRAME_ID_PATTERN: Final = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9_-]{0,95})" + r"__(?P[0-9]{4})" + r"_(?P[0-9]{16,20})$" +) READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = ( "Архивный UI-0 публикует только квалификационные доказательства; " "содержимое команд и mutation-маршруты в архиве отсутствуют.", @@ -288,16 +293,19 @@ def build_polygon_router( "preview_points": manifest["preview_points"], "frames": [ { - key: frame[key] - for key in ( - "sequence", - "frame_id", - "source_point_count", - "point_count", - "current", - "patchworkpp", - "ground_iou_delta", - ) + **{ + key: frame[key] + for key in ( + "sequence", + "frame_id", + "source_point_count", + "point_count", + "current", + "patchworkpp", + "ground_iou_delta", + ) + }, + **_goose_frame_identity(frame["frame_id"]), } for frame in manifest["frames"] ], @@ -717,6 +725,21 @@ def _read_ground_review_manifest( return pack_root, manifest +def _goose_frame_identity(frame_id: str) -> dict[str, str | int]: + match = GOOSE_FRAME_ID_PATTERN.fullmatch(frame_id) + if match is None: + raise HTTPException( + status_code=500, + detail="Идентичность GOOSE-кадра нарушает контракт.", + ) + return { + "dataset_sequence_id": match.group("dataset_sequence_id"), + "dataset_frame_number": int(match.group("dataset_frame_number")), + # Nanosecond Unix time exceeds JavaScript's safe integer range. + "sensor_timestamp_ns": match.group("sensor_timestamp_ns"), + } + + def _valid_review_metrics(value: object) -> bool: if not isinstance(value, dict): return False diff --git a/tests/test_polygon_api.py b/tests/test_polygon_api.py index d48ddc5..16fd214 100644 --- a/tests/test_polygon_api.py +++ b/tests/test_polygon_api.py @@ -214,7 +214,7 @@ def _qualification_run(root: Path) -> tuple[QualificationRun, str]: evidence = root / admitted.run_id / "evidence" failures = evidence / "failures" failures.mkdir(parents=True) - frame_id = "2022-07-22_flight__0071_0001" + frame_id = "2022-07-22_flight__0071_1658494234334310308" report = { "schema_version": "missioncore.goose-ground-qualification-report/v1", "identity_sha256": SHA_A, @@ -403,9 +403,12 @@ def test_polygon_api_publishes_all_frame_review_without_paths(tmp_path: Path) -> "GET", )(run_id=run.run_id, frame_id=frame_id) - assert review["schema_version"] == "missioncore.polygon-ground-review/v1" + assert review["schema_version"] == "missioncore.polygon-ground-review/v2" assert review["frame_count"] == 1 assert review["frames"][0]["ground_iou_delta"] == pytest.approx(0.2) + assert review["frames"][0]["dataset_sequence_id"] == "2022-07-22_flight" + assert review["frames"][0]["dataset_frame_number"] == 71 + assert review["frames"][0]["sensor_timestamp_ns"] == "1658494234334310308" assert "relative_path" not in review["frames"][0] assert frame["schema_version"] == "missioncore.polygon-ground-review-frame/v1" assert frame["points_xyz_m"] == [[0.0, 0.0, 0.0], [1.0, -1.0, 0.5]]