fix(polygon): unify dataset review workflow

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 19:54:22 +03:00
parent e97573e221
commit 13c35ff446
15 changed files with 568 additions and 509 deletions
+14 -12
View File
@@ -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<RootId | null>(
polygonRunRoute.active ? "polygon" : null,
polygonDatasetRoute.active ? "polygon" : null,
);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
@@ -194,7 +194,7 @@ export default function App() {
const replayActiveRef = useRef(false);
const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | 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<SceneSettings>({
@@ -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() {
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
<Icon name="activity" />
</span>
<span>{rootWorkspaces.length} рабочих поверхностей</span>
<span>
{rootWorkspaces.length}{" "}
{rootWorkspaces.length === 1
? "рабочая поверхность"
: "рабочих поверхностей"}
</span>
</>
}
/>
@@ -740,8 +745,6 @@ export default function App() {
</span>
) : null}
</div>
) : activeDefinition.kind === "polygon-run" ? (
<StatusBadge tone="accent">Virtual only</StatusBadge>
) : activeDefinition.kind === "polygon-datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : (
@@ -772,7 +775,6 @@ export default function App() {
livePerceptionLayers={livePerceptionLayers}
onLivePerceptionLayersChange={changeLivePerceptionLayers}
observationLayout={observationLayout}
polygonRunRoute={polygonRunRoute}
deviceLabel={selection?.model.displayName ?? null}
spatialControls={selection?.SpatialControlsView
? {
@@ -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");
+2 -14
View File
@@ -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",
+12 -1
View File
@@ -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,
@@ -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,
@@ -125,18 +125,17 @@ export function DatasetGatewayWorkspace() {
<span className="section-eyebrow">ЗАЧЕМ ЭТО НУЖНО</span>
<h2>Независимая проверка алгоритмов</h2>
<p>
Датасет это вход квалификационного прогона. Мы подаём известные
размеченные данные в выбранный pipeline, измеряем ошибки и сохраняем
воспроизводимое решение. Он не подмешивается в диагностику
реального сенсора и не
заменяет closed-loop симуляцию.
Датасет содержит известные размеченные данные. Мы показываем
конкретный фрагмент, результаты каждого алгоритма и ошибки
относительно разметки. Он не подмешивается в диагностику реального
сенсора и не заменяет closed-loop симуляцию.
</p>
</div>
<ol aria-label="Последовательность квалификации">
<li><span>01</span>Выбрать данные</li>
<li><span>02</span>Запустить pipeline</li>
<li><span>03</span>Сравнить с разметкой</li>
<li><span>04</span>Принять решение</li>
<ol aria-label="Как использовать датасет">
<li><span>01</span>Выбрать фрагмент</li>
<li><span>02</span>Посмотреть сканы</li>
<li><span>03</span>Сравнить алгоритмы</li>
<li><span>04</span>Проверить риски</li>
</ol>
</section>
@@ -179,9 +178,10 @@ export function DatasetGatewayWorkspace() {
<span>PUBLIC · OFF-ROAD · LABELED</span>
<h3>{catalog.source.displayName}</h3>
<p>
Validation split: восемь отдельных поездок и 961 разреженный
размеченный оборот VLS-128. Это коллекция native scans для
perception, а не непрерывное видео или траектория машины.
Validation split: восемь фрагментов исходных записей и 961
разреженный размеченный оборот VLS-128. Это коллекция native
scans для perception, а не непрерывное видео или траектория
машины.
</p>
</div>
</div>
@@ -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 ? "Закрыть источник" : "Открыть источник"}
</Button>
<Button
size="compact"
variant="secondary"
title="Открыть read-only архив квалификационных прогонов"
onClick={() => window.location.assign("/?workspace=polygon-run")}
>
Открыть прогоны
{previewOpen ? "Закрыть датасет" : "Открыть датасет"}
</Button>
</div>
</footer>
@@ -254,20 +246,20 @@ export function DatasetGatewayWorkspace() {
{previewOpen && selectedRunId ? (
<>
{gooseRuns.length > 0 ? (
{gooseRuns.length > 1 ? (
<div className="dataset-result-select">
<div>
<span className="section-eyebrow">НАЛОЖЕНИЕ РЕЗУЛЬТАТА</span>
<strong>Квалификационный прогон</strong>
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
<strong>Версия анализа</strong>
</div>
<select
aria-label="Выбрать прогон для наложения"
aria-label="Выбрать версию анализа"
value={selectedRunId}
onChange={(event) => setSelectedRunId(event.target.value)}
>
{gooseRuns.map((item) => (
<option key={item.runId} value={item.runId}>
{item.scenarioGeneration} · {item.runId}
{item.profileGeneration}
</option>
))}
</select>
@@ -278,8 +270,8 @@ export function DatasetGatewayWorkspace() {
) : previewOpen ? (
<section className="polygon-review-unavailable">
<StatusBadge tone="warning">Review недоступен</StatusBadge>
<h3>Источник принят, но покадровый preview не опубликован</h3>
<p>{runError ?? "Нет квалификационного прогона с bounded frame-pack."}</p>
<h3>Датасет принят, но результаты анализа не опубликованы</h3>
<p>{runError ?? "Нет совместимой версии покадрового анализа."}</p>
</section>
) : null}
@@ -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<string, GroundReviewFrame>());
const playerRef = useRef<HTMLElement>(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 (
<section className="polygon-review-unavailable">
<StatusBadge tone="accent">Читаем индекс</StatusBadge>
<h3>Открываем последовательности GOOSE</h3>
<h3>Открываем фрагменты GOOSE</h3>
<p>Загружаем только bounded preview, исходный архив остаётся на worker.</p>
</section>
);
@@ -286,23 +401,24 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
<section className="dataset-sequence-review" aria-label="Просмотр GOOSE validation">
<header className="dataset-sequence-review__truth">
<div>
<span className="section-eyebrow">СТРУКТУРА ИСХОДНИКА</span>
<h2>8 отдельных поездок, а не одно видео</h2>
<span className="section-eyebrow">СОДЕРЖИМОЕ ДАТАСЕТА</span>
<h2>8 отдельных фрагментов исходных записей</h2>
<p>
Validation ZIP содержит {review.frameCount} разреженных размеченных
LiDAR-оборотов. Между ними нет непрерывной траектории машины. Play ниже
ускоренный просмотр выбранной поездки, а не воспроизведение реального времени.
LiDAR-оборотов. Каждый фрагмент относится к отдельному эпизоду съёмки.
Play ниже ускоренный просмотр доступных сканов выбранного фрагмента,
а не воспроизведение движения машины в реальном времени.
</p>
</div>
<dl>
<div><dt>Последовательностей</dt><dd>{sequences.length}</dd></div>
<div><dt>Фрагментов</dt><dd>{sequences.length}</dd></div>
<div><dt>Размеченных сканов</dt><dd>{review.frameCount}</dd></div>
<div><dt>Preview</dt><dd>{review.previewPoints.toLocaleString("ru-RU")} точек</dd></div>
</dl>
</header>
<div className="dataset-sequence-select">
<label htmlFor="goose-sequence">Последовательность</label>
<label htmlFor="goose-sequence">Фрагмент датасета</label>
<select
id="goose-sequence"
value={selectedSequence.datasetSequenceId}
@@ -314,19 +430,19 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
value={sequence.datasetSequenceId}
>
{sequenceLabel(sequence.datasetSequenceId)}
{" · "}{sequence.frames.length} scans
{" · "}{sequence.frames.length} сканов
</option>
))}
</select>
<span>
{selectedSequence.frames.length} размеченных сканов · интервал{" "}
{selectedSequence.frames.length} сканов · интервалы{" "}
{formatGap(selectedSequence.minimumGapSeconds)}{
formatGap(selectedSequence.maximumGapSeconds)
} · охват {formatDuration(selectedSequence.durationSeconds)}
</span>
</div>
<section className="polygon-review-player">
<section className="polygon-review-player" ref={playerRef}>
<header className="polygon-review-player__header">
<div>
<span className="section-eyebrow">
@@ -352,7 +468,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
<div className="polygon-review-controls">
<button
type="button"
aria-label={playing ? "Пауза" : "Воспроизвести последовательность"}
aria-label={playing ? "Пауза" : "Воспроизвести фрагмент"}
onClick={() => setPlaying((value) => !value)}
>
{playing ? "Пауза" : "Play review"}
@@ -378,7 +494,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
min={0}
max={Math.max(0, selectedSequence.frames.length - 1)}
value={selectedPosition}
aria-label="Позиция в последовательности"
aria-label="Позиция во фрагменте"
onChange={(event) => selectPosition(Number(event.target.value))}
/>
<span>{selectedPosition + 1} / {selectedSequence.frames.length}</span>
@@ -411,26 +527,26 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
<small>реальный timestamp, не cadence playback</small>
</article>
<article>
<span>Current · Ground IoU</span>
<span>Текущий алгоритм · Ground IoU</span>
<strong>{formatPercent(selectedSummary.current.groundIou)}</strong>
<small>overlay выбранного прогона</small>
<small>только выбранный скан</small>
</article>
<article>
<span>Patchwork++ · Ground IoU</span>
<strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong>
<small>overlay выбранного прогона</small>
<small>только выбранный скан</small>
</article>
<article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}>
<span>Разница</span>
<strong>{formatDelta(selectedSummary.groundIouDelta)}</strong>
<small>Patchwork++ минус Current</small>
<small>Patchwork++ минус текущий алгоритм</small>
</article>
</div>
) : null}
<div
className="polygon-review-timeline dataset-sequence-timeline"
aria-label={`Все ${selectedSequence.frames.length} сканов последовательности`}
aria-label={`Все ${selectedSequence.frames.length} сканов фрагмента`}
>
{selectedSequence.frames.map((frame, index) => (
<button
@@ -445,11 +561,110 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
))}
</div>
<p className="polygon-review-timeline-note">
Риски показывают только доступные размеченные scans этой поездки.
Пропуски времени не интерполируются; ракурс сохраняется между соседними scans.
Полоса показывает только доступные размеченные сканы этого фрагмента.
Пропуски времени не интерполируются; ракурс сохраняется между соседними сканами.
</p>
</section>
{sequenceAnalysis ? (
<section
className="dataset-fragment-analysis"
aria-label="Результаты выбранного фрагмента"
>
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ЭТОГО ФРАГМЕНТА</span>
<h3>Что изменилось после Patchwork++</h3>
<p>
Средние значения рассчитаны только по{" "}
{selectedSequence.frames.length} сканам выбранного фрагмента.
Они не смешиваются с остальными частями датасета.
</p>
</div>
<div className="dataset-fragment-analysis__verdict">
<strong>{formatDelta(sequenceAnalysis.groundIou.delta)}</strong>
<span>средний Ground IoU</span>
<small>
лучше на {sequenceAnalysis.patchworkBetterFrames} · хуже на{" "}
{sequenceAnalysis.currentBetterFrames} · без изменения на{" "}
{sequenceAnalysis.unchangedFrames}
</small>
</div>
</header>
<div className="dataset-fragment-comparison">
{([
["Ground IoU", sequenceAnalysis.groundIou],
["Natural ground recall", sequenceAnalysis.naturalGroundRecall],
["Obstacle preservation", sequenceAnalysis.obstacleNonGroundRecall],
] as const).map(([label, metric]) => (
<article key={label}>
<span>{label}</span>
<div>
<small>Текущий</small>
<strong>{formatPercent(metric.current)}</strong>
</div>
<i></i>
<div>
<small>Patchwork++</small>
<strong>{formatPercent(metric.patchwork)}</strong>
</div>
<em data-positive={metric.delta >= 0 ? "true" : "false"}>
{formatDelta(metric.delta)}
</em>
</article>
))}
</div>
<div className="dataset-fragment-risks">
<button
type="button"
onClick={() => inspectFrame(
sequenceAnalysis.worstRegressionFrame,
"candidate-disagreement",
)}
>
<span>Самое сильное ухудшение</span>
<strong>{formatDelta(sequenceAnalysis.worstRegressionFrame.groundIouDelta)}</strong>
<small>
source frame {sequenceAnalysis.worstRegressionFrame.datasetFrameNumber}
</small>
</button>
<button
type="button"
onClick={() => inspectFrame(
sequenceAnalysis.weakestPatchworkFrame,
"candidate",
)}
>
<span>Самый слабый результат Patchwork++</span>
<strong>
{formatPercent(sequenceAnalysis.weakestPatchworkFrame.patchwork.groundIou)}
</strong>
<small>
source frame {sequenceAnalysis.weakestPatchworkFrame.datasetFrameNumber}
</small>
</button>
<button
type="button"
disabled={!sequenceAnalysis.largestGapFrame}
onClick={() => inspectFrame(
sequenceAnalysis.largestGapFrame,
"intensity",
)}
>
<span>Самый большой пропуск записи</span>
<strong>{formatGap(sequenceAnalysis.largestGapSeconds)}</strong>
<small>
{sequenceAnalysis.largestGapFrame
? `перед source frame ${sequenceAnalysis.largestGapFrame.datasetFrameNumber}`
: "фрагмент содержит один скан"}
</small>
</button>
</div>
</section>
) : null}
<p className="dataset-sequence-review__raw-note">
Для настоящего движения машины, odometry и синхронного sensor playback нужен
отдельный raw ROS bag GOOSE с localization. Он не входит в принятый 3.3 ГБ
@@ -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<PolygonRunState, string> = {
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<PolygonRunCatalog | null>(null);
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
const [qualification, setQualification] = useState<GroundQualification | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(route.runId);
const [loading, setLoading] = useState(route.error === null);
const [error, setError] = useState<string | null>(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 (
<div className="standard-workspace polygon-run-workspace">
<section className="polygon-run-message">
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<h2>Открываем историю прогонов</h2>
<p>Читаем каталог и выбранный воспроизводимый запуск.</p>
</section>
</div>
);
}
if (error) {
return (
<div className="standard-workspace polygon-run-workspace">
<section className="polygon-run-message">
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
<h2>Не удалось открыть прогон</h2>
<p>{error}</p>
<Button
size="compact"
variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)}
>
Повторить
</Button>
</section>
</div>
);
}
if (!detail) {
return (
<div className="standard-workspace polygon-run-workspace">
<section className="polygon-run-message">
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<h2>Прогонов пока нет</h2>
<p>Здесь появятся simulation, dataset replay и shadow-запуски.</p>
</section>
</div>
);
}
const { run } = detail;
return (
<div className="standard-workspace polygon-run-workspace">
<section className="polygon-run-picker">
<div>
<span className="section-eyebrow">ИСТОРИЯ ПОЛИГОНА</span>
<strong>Выбранный прогон</strong>
</div>
<select
aria-label="Выбрать прогон"
value={run.runId}
disabled={!catalog?.items.length}
onChange={(event) => setSelectedRunId(event.target.value)}
>
{(catalog?.items ?? []).map((item) => (
<option key={item.runId} value={item.runId}>
{runKindLabel(item.kind)} · {item.scenarioGeneration} · {item.runId}
</option>
))}
</select>
<small>{catalog?.total ?? 0} в архиве</small>
</section>
<section className="polygon-review-lead">
<div>
<span className="section-eyebrow">{runKindLabel(run.kind).toUpperCase()}</span>
<h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
<p>
{qualification
? "Результат алгоритмического replay. Сам исходный датасет и его sequences открываются в разделе «Датасеты»."
: "Воспроизводимая запись запуска Полигона."}
</p>
</div>
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
</section>
{qualification ? (
<>
<section className="polygon-run-input">
<div>
<span className="section-eyebrow">ВХОД ПРОГОНА</span>
<h3>GOOSE 3D · validation</h3>
<p>
Восемь отдельных поездок, 961 разреженный размеченный LiDAR scan.
Это не closed-loop simulation и не непрерывная запись движения машины.
</p>
</div>
<Button
size="compact"
variant="secondary"
onClick={() => window.location.assign(
`/?workspace=polygon-datasets&run=${encodeURIComponent(run.runId)}`,
)}
>
Открыть исходник
</Button>
</section>
<section className="polygon-review-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ ПРОГОНА</span>
<h3>Current против Patchwork++</h3>
</div>
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
{qualification.decision.passed ? "27 / 27 проверок" : "Есть провалы"}
</StatusBadge>
</header>
<p>
Оба алгоритма независимо обработали одни и те же 961 scans.
Покадровая визуальная проверка теперь находится рядом с самим входом
в «Датасетах», а не притворяется симуляцией машины.
</p>
<div>
<article>
<span>Ground IoU</span>
<strong>{formatPercent(qualification.current.micro.groundIou)}</strong>
<i></i>
<strong>{formatPercent(qualification.patchwork.micro.groundIou)}</strong>
<small>{formatDelta(
qualification.patchwork.micro.groundIou
- qualification.current.micro.groundIou,
)}</small>
</article>
<article>
<span>Natural ground recall</span>
<strong>{formatPercent(
qualification.current.micro.naturalGroundRecall,
)}</strong>
<i></i>
<strong>{formatPercent(
qualification.patchwork.micro.naturalGroundRecall,
)}</strong>
<small>{formatDelta(
qualification.patchwork.micro.naturalGroundRecall
- qualification.current.micro.naturalGroundRecall,
)}</small>
</article>
<article>
<span>Latency p95</span>
<strong>{qualification.current.latencyMs.p95.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} мс</strong>
<i></i>
<strong>{qualification.patchwork.latencyMs.p95.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} мс</strong>
<small>на scan</small>
</article>
</div>
</section>
<details className="polygon-review-analysis">
<summary>Acceptance и деградации</summary>
<div className="polygon-review-analysis__content">
<section>
<span className="section-eyebrow">ACCEPTANCE GATES</span>
{qualification.checks.map((check) => (
<p key={check.checkId} data-passed={check.passed ? "true" : "false"}>
<i aria-hidden="true" />
<span>{check.checkId}</span>
<code>
{check.observed.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
{" "}{check.operator}{" "}
{check.threshold.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
</code>
</p>
))}
</section>
<section>
<span className="section-eyebrow">ДЕГРАДАЦИИ</span>
{Object.entries(qualification.degradations).map(([profileId, aggregate]) => (
<p key={profileId}>
<strong>{profileId}</strong>
<span>IoU {formatPercent(aggregate.micro.groundIou)}</span>
<span>Natural {formatPercent(aggregate.micro.naturalGroundRecall)}</span>
<span>p95 {aggregate.latencyMs.p95.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} мс</span>
</p>
))}
</section>
</div>
</details>
</>
) : (
<section className="polygon-review-unavailable">
<span className="section-eyebrow">АРХИВНЫЙ ПРОГОН</span>
<h3>Для этого запуска нет perception qualification</h3>
<p>Техническая идентичность доступна ниже.</p>
</section>
)}
<details className="polygon-run-technical">
<summary>Технические детали прогона</summary>
<div className="polygon-run-technical__content">
<dl>
<div><dt>Run ID</dt><dd>{run.runId}</dd></div>
<div><dt>Тип</dt><dd>{runKindLabel(run.kind)}</dd></div>
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
<div><dt>Mission Core</dt><dd>{run.missionCoreCommit.slice(0, 12)}</dd></div>
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
</dl>
<section>
<h4>Провайдеры</h4>
{run.providers.map((provider) => (
<p key={provider.identifier}>
<strong>{provider.identifier}</strong>
<span>{provider.version} · {provider.revision.slice(0, 16)}</span>
</p>
))}
</section>
<section>
<h4>Артефакты</h4>
{detail.artifacts.map((artifact) => (
<p key={artifact.artifactId}>
<strong>{artifact.kind}</strong>
<span>{formatBytes(artifact.byteLength)} · {artifact.sha256.slice(0, 12)}</span>
</p>
))}
</section>
</div>
</details>
</div>
);
}
@@ -22,7 +22,6 @@ import {
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
import type { PolygonRunRoute } from "../core/polygon/runArchive";
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type {
RecordedAdmissionPhase,
@@ -56,7 +55,6 @@ import {
} from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
@@ -140,7 +138,6 @@ export interface WorkspaceRendererProps {
cuboids3d: boolean;
}) => void;
observationLayout: ObservationLayoutController;
polygonRunRoute: PolygonRunRoute;
deviceLabel: string | null;
navigation: WorkspaceNavigation;
spatialControls: {
@@ -1318,8 +1315,6 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
);
case "polygon-datasets":
return <DatasetGatewayWorkspace />;
case "polygon-run":
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
case "device":
return null;
}
@@ -12,6 +12,7 @@ let fetchGroundQualification;
let GroundQualificationContractError;
let parseGooseFrameId;
let groupGooseReviewFrames;
let analyzeGooseDatasetSequence;
const aggregate = {
micro: {
@@ -81,6 +82,7 @@ before(async () => {
parseGooseFrameId,
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
({
analyzeGooseDatasetSequence,
groupGooseReviewFrames,
} = await server.ssrLoadModule("/src/workspaces/GooseDatasetReview.tsx"));
});
@@ -230,6 +232,70 @@ test("keeps independent GOOSE recordings in separate playback sequences", () =>
assert.ok(groups[0].durationSeconds > 1.6);
});
test("summarizes only the selected dataset fragment", () => {
const frames = [
{
sequence: 0,
frameId: "2022-07-22_flight__0071_1658494234334310308",
datasetSequenceId: "2022-07-22_flight",
datasetFrameNumber: 71,
sensorTimestampNs: "1658494234334310308",
sourcePointCount: 100_000,
pointCount: 12_000,
current: {
groundIou: 0.4,
naturalGroundRecall: 0.5,
obstacleNonGroundRecall: 0.9,
latencyMs: 100,
},
patchwork: {
groundIou: 0.6,
naturalGroundRecall: 0.7,
obstacleNonGroundRecall: 0.95,
latencyMs: 20,
},
groundIouDelta: 0.2,
},
{
sequence: 1,
frameId: "2022-07-22_flight__0072_1658494244334310308",
datasetSequenceId: "2022-07-22_flight",
datasetFrameNumber: 72,
sensorTimestampNs: "1658494244334310308",
sourcePointCount: 100_000,
pointCount: 12_000,
current: {
groundIou: 0.7,
naturalGroundRecall: 0.8,
obstacleNonGroundRecall: 0.94,
latencyMs: 100,
},
patchwork: {
groundIou: 0.6,
naturalGroundRecall: 0.75,
obstacleNonGroundRecall: 0.93,
latencyMs: 20,
},
groundIouDelta: -0.1,
},
];
const analysis = analyzeGooseDatasetSequence({
datasetSequenceId: "2022-07-22_flight",
frames,
durationSeconds: 10,
minimumGapSeconds: 10,
maximumGapSeconds: 10,
});
assert.equal(analysis.groundIou.current, 0.55);
assert.equal(analysis.groundIou.patchwork, 0.6);
assert.ok(Math.abs(analysis.groundIou.delta - 0.05) < 1e-12);
assert.equal(analysis.patchworkBetterFrames, 1);
assert.equal(analysis.currentBetterFrames, 1);
assert.equal(analysis.worstRegressionFrame.datasetFrameNumber, 72);
assert.equal(analysis.largestGapFrame.datasetFrameNumber, 72);
assert.equal(analysis.largestGapSeconds, 10);
});
test("decodes a point-aligned review frame with intensity", () => {
const decoded = decodeGroundReviewFrame({
schema_version: "missioncore.polygon-ground-review-frame/v1",
@@ -290,7 +290,7 @@ function commandAcceptance(overrides = {}) {
};
}
test("Polygon stays visible while worker capability gates live actions", () => {
test("Polygon exposes one dataset surface and keeps legacy links compatible", () => {
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
active: true,
runId: null,
@@ -304,18 +304,27 @@ test("Polygon stays visible while worker capability gates live actions", () => {
error: null,
},
);
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-datasets"), {
active: true,
runId: null,
error: null,
});
assert.equal(resolvePolygonRunRoute("?workspace=missions").active, false);
assert.match(
resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error,
/некорректный/,
);
assert.equal(workspaceById("polygon-run").root, "polygon");
assert.equal(workspaceById("polygon-run"), null);
assert.equal(
workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"),
true,
false,
);
assert.equal(workspaceById("polygon-datasets").root, "polygon");
assert.equal(workspaceById("polygon-datasets").kind, "polygon-datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["polygon-datasets"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
false,