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

View File

@ -150,7 +150,7 @@ function mergeViewerSettings(
export default function App() { export default function App() {
const runtime = useMissionRuntime(); const runtime = useMissionRuntime();
const { selection } = useDevicePluginHost(); const { selection } = useDevicePluginHost();
const polygonRunRoute = useMemo( const polygonDatasetRoute = useMemo(
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search), () => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
[], [],
); );
@ -160,7 +160,7 @@ export default function App() {
}); });
const [activeRoot, setActiveRoot] = useState<RootId | null>( const [activeRoot, setActiveRoot] = useState<RootId | null>(
polygonRunRoute.active ? "polygon" : null, polygonDatasetRoute.active ? "polygon" : null,
); );
const [sourceUrl, setSourceUrl] = useState(""); const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null); const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
@ -194,7 +194,7 @@ export default function App() {
const replayActiveRef = useRef(false); const replayActiveRef = useRef(false);
const sceneSettingsCommitterActiveRef = useRef(true); const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null); const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const polygonRunRouteOpenedRef = useRef(false); const polygonDatasetRouteOpenedRef = useRef(false);
const currentRoot = rootById(activeRoot); const currentRoot = rootById(activeRoot);
const visibleRoots = roots; const visibleRoots = roots;
@ -214,10 +214,10 @@ export default function App() {
replayActiveRef.current = replayActive; replayActiveRef.current = replayActive;
useEffect(() => { useEffect(() => {
if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return; if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
polygonRunRouteOpenedRef.current = true; polygonDatasetRouteOpenedRef.current = true;
workspace.openView("polygon-run"); workspace.openView("polygon-datasets");
}, [polygonRunRoute.active, workspace]); }, [polygonDatasetRoute.active, workspace]);
if (!sceneSettingsCommitterRef.current) { if (!sceneSettingsCommitterRef.current) {
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({ sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
@ -415,7 +415,7 @@ export default function App() {
const selectRoot = (rootId: RootId) => { const selectRoot = (rootId: RootId) => {
setActiveRoot(rootId); setActiveRoot(rootId);
if (rootId === "polygon") { if (rootId === "polygon") {
workspace.openView("polygon-run"); workspace.openView("polygon-datasets");
workspace.openNavigation(); workspace.openNavigation();
return; return;
} }
@ -694,7 +694,12 @@ export default function App() {
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"> <span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
<Icon name="activity" /> <Icon name="activity" />
</span> </span>
<span>{rootWorkspaces.length} рабочих поверхностей</span> <span>
{rootWorkspaces.length}{" "}
{rootWorkspaces.length === 1
? "рабочая поверхность"
: "рабочих поверхностей"}
</span>
</> </>
} }
/> />
@ -740,8 +745,6 @@ export default function App() {
</span> </span>
) : null} ) : null}
</div> </div>
) : activeDefinition.kind === "polygon-run" ? (
<StatusBadge tone="accent">Virtual only</StatusBadge>
) : activeDefinition.kind === "polygon-datasets" ? ( ) : activeDefinition.kind === "polygon-datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge> <StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : ( ) : (
@ -772,7 +775,6 @@ export default function App() {
livePerceptionLayers={livePerceptionLayers} livePerceptionLayers={livePerceptionLayers}
onLivePerceptionLayersChange={changeLivePerceptionLayers} onLivePerceptionLayersChange={changeLivePerceptionLayers}
observationLayout={observationLayout} observationLayout={observationLayout}
polygonRunRoute={polygonRunRoute}
deviceLabel={selection?.model.displayName ?? null} deviceLabel={selection?.model.displayName ?? null}
spatialControls={selection?.SpatialControlsView spatialControls={selection?.SpatialControlsView
? { ? {

View File

@ -552,7 +552,10 @@ export function decodePolygonRunDetail(payload: unknown): PolygonRunDetail {
export function resolvePolygonRunRoute(search: string): PolygonRunRoute { export function resolvePolygonRunRoute(search: string): PolygonRunRoute {
const parameters = new URLSearchParams(search); const parameters = new URLSearchParams(search);
const workspaceValues = parameters.getAll("workspace"); 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 }; return { active: false, runId: null, error: null };
} }
const runValues = parameters.getAll("run"); const runValues = parameters.getAll("run");

View File

@ -19,8 +19,7 @@ export type WorkspaceKind =
| "missions" | "missions"
| "catalog" | "catalog"
| "lidar-quality" | "lidar-quality"
| "polygon-datasets" | "polygon-datasets";
| "polygon-run";
export type CapabilityStatus = "active" | "ready" | "contract" | "later"; export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@ -134,7 +133,7 @@ export const roots: RootDefinition[] = [
label: "Полигон", label: "Полигон",
title: "Полигон", title: "Полигон",
eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ", eyebrow: "СИМУЛЯЦИЯ И КВАЛИФИКАЦИЯ",
description: "Симуляционные, replay и shadow-прогоны с воспроизводимыми доказательствами.", description: "Датасеты и виртуальные среды для воспроизводимой проверки алгоритмов.",
statement: "Проверять алгоритмы и поведение машины на версионированных входах.", statement: "Проверять алгоритмы и поведение машины на версионированных входах.",
accent: "КВАЛИФИКАЦИЯ И ДОКАЗАТЕЛЬСТВА", accent: "КВАЛИФИКАЦИЯ И ДОКАЗАТЕЛЬСТВА",
}, },
@ -150,17 +149,6 @@ export const roots: RootDefinition[] = [
]; ];
export const workspaces: WorkspaceDefinition[] = [ 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", id: "polygon-datasets",
root: "polygon", root: "polygon",

View File

@ -200,7 +200,8 @@
.polygon-review-lead, .polygon-review-lead,
.polygon-review-player__header, .polygon-review-player__header,
.polygon-review-order, .polygon-review-order,
.polygon-review-summary > header { .polygon-review-summary > header,
.dataset-fragment-analysis > header {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
@ -217,6 +218,16 @@
grid-template-columns: 1fr; 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, .polygon-run-picker,
.dataset-result-select, .dataset-result-select,
.dataset-sequence-select, .dataset-sequence-select,

View File

@ -1441,6 +1441,151 @@
line-height: 1.45; 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-summary,
.polygon-review-unavailable, .polygon-review-unavailable,
.polygon-review-analysis, .polygon-review-analysis,

View File

@ -125,18 +125,17 @@ export function DatasetGatewayWorkspace() {
<span className="section-eyebrow">ЗАЧЕМ ЭТО НУЖНО</span> <span className="section-eyebrow">ЗАЧЕМ ЭТО НУЖНО</span>
<h2>Независимая проверка алгоритмов</h2> <h2>Независимая проверка алгоритмов</h2>
<p> <p>
Датасет это вход квалификационного прогона. Мы подаём известные Датасет содержит известные размеченные данные. Мы показываем
размеченные данные в выбранный pipeline, измеряем ошибки и сохраняем конкретный фрагмент, результаты каждого алгоритма и ошибки
воспроизводимое решение. Он не подмешивается в диагностику относительно разметки. Он не подмешивается в диагностику реального
реального сенсора и не сенсора и не заменяет closed-loop симуляцию.
заменяет closed-loop симуляцию.
</p> </p>
</div> </div>
<ol aria-label="Последовательность квалификации"> <ol aria-label="Как использовать датасет">
<li><span>01</span>Выбрать данные</li> <li><span>01</span>Выбрать фрагмент</li>
<li><span>02</span>Запустить pipeline</li> <li><span>02</span>Посмотреть сканы</li>
<li><span>03</span>Сравнить с разметкой</li> <li><span>03</span>Сравнить алгоритмы</li>
<li><span>04</span>Принять решение</li> <li><span>04</span>Проверить риски</li>
</ol> </ol>
</section> </section>
@ -179,9 +178,10 @@ export function DatasetGatewayWorkspace() {
<span>PUBLIC · OFF-ROAD · LABELED</span> <span>PUBLIC · OFF-ROAD · LABELED</span>
<h3>{catalog.source.displayName}</h3> <h3>{catalog.source.displayName}</h3>
<p> <p>
Validation split: восемь отдельных поездок и 961 разреженный Validation split: восемь фрагментов исходных записей и 961
размеченный оборот VLS-128. Это коллекция native scans для разреженный размеченный оборот VLS-128. Это коллекция native
perception, а не непрерывное видео или траектория машины. scans для perception, а не непрерывное видео или траектория
машины.
</p> </p>
</div> </div>
</div> </div>
@ -232,20 +232,12 @@ export function DatasetGatewayWorkspace() {
disabled={catalog.source.admissionStatus !== "frame-ready"} disabled={catalog.source.admissionStatus !== "frame-ready"}
title={ title={
catalog.source.admissionStatus === "frame-ready" catalog.source.admissionStatus === "frame-ready"
? "Открыть все доступные последовательности и scans" ? "Открыть фрагменты, сканы и результаты анализа"
: "Размеченные scans ещё не импортированы" : "Размеченные сканы ещё не импортированы"
} }
onClick={() => setPreviewOpen((value) => !value)} onClick={() => setPreviewOpen((value) => !value)}
> >
{previewOpen ? "Закрыть источник" : "Открыть источник"} {previewOpen ? "Закрыть датасет" : "Открыть датасет"}
</Button>
<Button
size="compact"
variant="secondary"
title="Открыть read-only архив квалификационных прогонов"
onClick={() => window.location.assign("/?workspace=polygon-run")}
>
Открыть прогоны
</Button> </Button>
</div> </div>
</footer> </footer>
@ -254,20 +246,20 @@ export function DatasetGatewayWorkspace() {
{previewOpen && selectedRunId ? ( {previewOpen && selectedRunId ? (
<> <>
{gooseRuns.length > 0 ? ( {gooseRuns.length > 1 ? (
<div className="dataset-result-select"> <div className="dataset-result-select">
<div> <div>
<span className="section-eyebrow">НАЛОЖЕНИЕ РЕЗУЛЬТАТА</span> <span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
<strong>Квалификационный прогон</strong> <strong>Версия анализа</strong>
</div> </div>
<select <select
aria-label="Выбрать прогон для наложения" aria-label="Выбрать версию анализа"
value={selectedRunId} value={selectedRunId}
onChange={(event) => setSelectedRunId(event.target.value)} onChange={(event) => setSelectedRunId(event.target.value)}
> >
{gooseRuns.map((item) => ( {gooseRuns.map((item) => (
<option key={item.runId} value={item.runId}> <option key={item.runId} value={item.runId}>
{item.scenarioGeneration} · {item.runId} {item.profileGeneration}
</option> </option>
))} ))}
</select> </select>
@ -278,8 +270,8 @@ export function DatasetGatewayWorkspace() {
) : previewOpen ? ( ) : previewOpen ? (
<section className="polygon-review-unavailable"> <section className="polygon-review-unavailable">
<StatusBadge tone="warning">Review недоступен</StatusBadge> <StatusBadge tone="warning">Review недоступен</StatusBadge>
<h3>Источник принят, но покадровый preview не опубликован</h3> <h3>Датасет принят, но результаты анализа не опубликованы</h3>
<p>{runError ?? "Нет квалификационного прогона с bounded frame-pack."}</p> <p>{runError ?? "Нет совместимой версии покадрового анализа."}</p>
</section> </section>
) : null} ) : null}

View File

@ -31,18 +31,37 @@ export interface GooseDatasetSequence {
maximumGapSeconds: number; 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]> = [ const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [
["intensity", "Исходный скан"], ["intensity", "Исходный скан"],
["ground-truth", "Ground truth"], ["ground-truth", "Ground truth"],
["current", "Current"], ["current", "Текущий алгоритм"],
["candidate", "Patchwork++"], ["candidate", "Patchwork++"],
["disagreement", "Ошибки Current"], ["disagreement", "Ошибки текущего"],
["candidate-disagreement", "Ошибки Patchwork++"], ["candidate-disagreement", "Ошибки Patchwork++"],
]; ];
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message; if (error instanceof Error && error.message.trim()) return error.message;
return "Не удалось открыть последовательности GOOSE."; return "Не удалось открыть фрагменты GOOSE.";
} }
function secondsBetween( 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 { function sequenceLabel(sequenceId: string): string {
const date = sequenceId.slice(0, 10); const date = sequenceId.slice(0, 10);
const title = sequenceId.slice(11).replaceAll("_", " "); const title = sequenceId.slice(11).replaceAll("_", " ");
@ -130,9 +226,11 @@ function frameScene(frame: GroundReviewFrame | null) {
function modeExplanation(mode: LidarGroundViewMode): string { function modeExplanation(mode: LidarGroundViewMode): string {
if (mode === "intensity") return "Один sensor-frame оборот VLS-128; цвет — remission."; if (mode === "intensity") return "Один sensor-frame оборот VLS-128; цвет — remission.";
if (mode === "ground-truth") return "Публичная point-aligned разметка GOOSE."; if (mode === "ground-truth") return "Публичная point-aligned разметка GOOSE.";
if (mode === "current") return "Результат Current для этого исходного скана."; if (mode === "current") return "Результат текущего алгоритма для этого исходного скана.";
if (mode === "candidate") return "Результат Patchwork++ для этого исходного скана."; if (mode === "candidate") return "Результат Patchwork++ для этого исходного скана.";
if (mode === "disagreement") return "Красным отмечены ошибки Current относительно labels."; if (mode === "disagreement") {
return "Красным отмечены ошибки текущего алгоритма относительно разметки.";
}
return "Красным отмечены ошибки Patchwork++ относительно labels."; return "Красным отмечены ошибки Patchwork++ относительно labels.";
} }
@ -146,6 +244,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [frameLoading, setFrameLoading] = useState(false); const [frameLoading, setFrameLoading] = useState(false);
const frameCache = useRef(new Map<string, GroundReviewFrame>()); const frameCache = useRef(new Map<string, GroundReviewFrame>());
const playerRef = useRef<HTMLElement>(null);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@ -175,6 +274,10 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
const selectedSequence = sequences.find( const selectedSequence = sequences.find(
(sequence) => sequence.datasetSequenceId === selectedSequenceId, (sequence) => sequence.datasetSequenceId === selectedSequenceId,
) ?? sequences[0] ?? null; ) ?? sequences[0] ?? null;
const sequenceAnalysis = useMemo(
() => selectedSequence ? analyzeGooseDatasetSequence(selectedSequence) : null,
[selectedSequence],
);
const selectedPosition = Math.max( const selectedPosition = Math.max(
0, 0,
selectedSequence?.frames.findIndex((frame) => frame.frameId === selectedFrameId) ?? 0, selectedSequence?.frames.findIndex((frame) => frame.frameId === selectedFrameId) ?? 0,
@ -252,6 +355,18 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
setSelectedFrameId(selectedSequence.frames[bounded]?.frameId ?? null); 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 selectSequence = (sequenceId: string) => {
const sequence = sequences.find((candidate) => ( const sequence = sequences.find((candidate) => (
candidate.datasetSequenceId === sequenceId candidate.datasetSequenceId === sequenceId
@ -276,7 +391,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
return ( return (
<section className="polygon-review-unavailable"> <section className="polygon-review-unavailable">
<StatusBadge tone="accent">Читаем индекс</StatusBadge> <StatusBadge tone="accent">Читаем индекс</StatusBadge>
<h3>Открываем последовательности GOOSE</h3> <h3>Открываем фрагменты GOOSE</h3>
<p>Загружаем только bounded preview, исходный архив остаётся на worker.</p> <p>Загружаем только bounded preview, исходный архив остаётся на worker.</p>
</section> </section>
); );
@ -286,23 +401,24 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
<section className="dataset-sequence-review" aria-label="Просмотр GOOSE validation"> <section className="dataset-sequence-review" aria-label="Просмотр GOOSE validation">
<header className="dataset-sequence-review__truth"> <header className="dataset-sequence-review__truth">
<div> <div>
<span className="section-eyebrow">СТРУКТУРА ИСХОДНИКА</span> <span className="section-eyebrow">СОДЕРЖИМОЕ ДАТАСЕТА</span>
<h2>8 отдельных поездок, а не одно видео</h2> <h2>8 отдельных фрагментов исходных записей</h2>
<p> <p>
Validation ZIP содержит {review.frameCount} разреженных размеченных Validation ZIP содержит {review.frameCount} разреженных размеченных
LiDAR-оборотов. Между ними нет непрерывной траектории машины. Play ниже LiDAR-оборотов. Каждый фрагмент относится к отдельному эпизоду съёмки.
ускоренный просмотр выбранной поездки, а не воспроизведение реального времени. Play ниже ускоренный просмотр доступных сканов выбранного фрагмента,
а не воспроизведение движения машины в реальном времени.
</p> </p>
</div> </div>
<dl> <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>Размеченных сканов</dt><dd>{review.frameCount}</dd></div>
<div><dt>Preview</dt><dd>{review.previewPoints.toLocaleString("ru-RU")} точек</dd></div> <div><dt>Preview</dt><dd>{review.previewPoints.toLocaleString("ru-RU")} точек</dd></div>
</dl> </dl>
</header> </header>
<div className="dataset-sequence-select"> <div className="dataset-sequence-select">
<label htmlFor="goose-sequence">Последовательность</label> <label htmlFor="goose-sequence">Фрагмент датасета</label>
<select <select
id="goose-sequence" id="goose-sequence"
value={selectedSequence.datasetSequenceId} value={selectedSequence.datasetSequenceId}
@ -314,19 +430,19 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
value={sequence.datasetSequenceId} value={sequence.datasetSequenceId}
> >
{sequenceLabel(sequence.datasetSequenceId)} {sequenceLabel(sequence.datasetSequenceId)}
{" · "}{sequence.frames.length} scans {" · "}{sequence.frames.length} сканов
</option> </option>
))} ))}
</select> </select>
<span> <span>
{selectedSequence.frames.length} размеченных сканов · интервал{" "} {selectedSequence.frames.length} сканов · интервалы{" "}
{formatGap(selectedSequence.minimumGapSeconds)}{ {formatGap(selectedSequence.minimumGapSeconds)}{
formatGap(selectedSequence.maximumGapSeconds) formatGap(selectedSequence.maximumGapSeconds)
} · охват {formatDuration(selectedSequence.durationSeconds)} } · охват {formatDuration(selectedSequence.durationSeconds)}
</span> </span>
</div> </div>
<section className="polygon-review-player"> <section className="polygon-review-player" ref={playerRef}>
<header className="polygon-review-player__header"> <header className="polygon-review-player__header">
<div> <div>
<span className="section-eyebrow"> <span className="section-eyebrow">
@ -352,7 +468,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
<div className="polygon-review-controls"> <div className="polygon-review-controls">
<button <button
type="button" type="button"
aria-label={playing ? "Пауза" : "Воспроизвести последовательность"} aria-label={playing ? "Пауза" : "Воспроизвести фрагмент"}
onClick={() => setPlaying((value) => !value)} onClick={() => setPlaying((value) => !value)}
> >
{playing ? "Пауза" : "Play review"} {playing ? "Пауза" : "Play review"}
@ -378,7 +494,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
min={0} min={0}
max={Math.max(0, selectedSequence.frames.length - 1)} max={Math.max(0, selectedSequence.frames.length - 1)}
value={selectedPosition} value={selectedPosition}
aria-label="Позиция в последовательности" aria-label="Позиция во фрагменте"
onChange={(event) => selectPosition(Number(event.target.value))} onChange={(event) => selectPosition(Number(event.target.value))}
/> />
<span>{selectedPosition + 1} / {selectedSequence.frames.length}</span> <span>{selectedPosition + 1} / {selectedSequence.frames.length}</span>
@ -411,26 +527,26 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
<small>реальный timestamp, не cadence playback</small> <small>реальный timestamp, не cadence playback</small>
</article> </article>
<article> <article>
<span>Current · Ground IoU</span> <span>Текущий алгоритм · Ground IoU</span>
<strong>{formatPercent(selectedSummary.current.groundIou)}</strong> <strong>{formatPercent(selectedSummary.current.groundIou)}</strong>
<small>overlay выбранного прогона</small> <small>только выбранный скан</small>
</article> </article>
<article> <article>
<span>Patchwork++ · Ground IoU</span> <span>Patchwork++ · Ground IoU</span>
<strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong> <strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong>
<small>overlay выбранного прогона</small> <small>только выбранный скан</small>
</article> </article>
<article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}> <article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}>
<span>Разница</span> <span>Разница</span>
<strong>{formatDelta(selectedSummary.groundIouDelta)}</strong> <strong>{formatDelta(selectedSummary.groundIouDelta)}</strong>
<small>Patchwork++ минус Current</small> <small>Patchwork++ минус текущий алгоритм</small>
</article> </article>
</div> </div>
) : null} ) : null}
<div <div
className="polygon-review-timeline dataset-sequence-timeline" className="polygon-review-timeline dataset-sequence-timeline"
aria-label={`Все ${selectedSequence.frames.length} сканов последовательности`} aria-label={`Все ${selectedSequence.frames.length} сканов фрагмента`}
> >
{selectedSequence.frames.map((frame, index) => ( {selectedSequence.frames.map((frame, index) => (
<button <button
@ -445,11 +561,110 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
))} ))}
</div> </div>
<p className="polygon-review-timeline-note"> <p className="polygon-review-timeline-note">
Риски показывают только доступные размеченные scans этой поездки. Полоса показывает только доступные размеченные сканы этого фрагмента.
Пропуски времени не интерполируются; ракурс сохраняется между соседними scans. Пропуски времени не интерполируются; ракурс сохраняется между соседними сканами.
</p> </p>
</section> </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"> <p className="dataset-sequence-review__raw-note">
Для настоящего движения машины, odometry и синхронного sensor playback нужен Для настоящего движения машины, odometry и синхронного sensor playback нужен
отдельный raw ROS bag GOOSE с localization. Он не входит в принятый 3.3 ГБ отдельный raw ROS bag GOOSE с localization. Он не входит в принятый 3.3 ГБ

View File

@ -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>
);
}

View File

@ -22,7 +22,6 @@ import {
import { ObservationTimeline } from "../components/ObservationTimeline"; import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow"; import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive"; import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
import type { PolygonRunRoute } from "../core/polygon/runArchive";
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission"; import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type { import type {
RecordedAdmissionPhase, RecordedAdmissionPhase,
@ -56,7 +55,6 @@ import {
} from "../productModel"; } from "../productModel";
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation"; import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings"; import type { SceneSettings } from "../sceneSettings";
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
import { LidarQualityWorkspace } from "./LidarQualityWorkspace"; import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace"; import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
@ -140,7 +138,6 @@ export interface WorkspaceRendererProps {
cuboids3d: boolean; cuboids3d: boolean;
}) => void; }) => void;
observationLayout: ObservationLayoutController; observationLayout: ObservationLayoutController;
polygonRunRoute: PolygonRunRoute;
deviceLabel: string | null; deviceLabel: string | null;
navigation: WorkspaceNavigation; navigation: WorkspaceNavigation;
spatialControls: { spatialControls: {
@ -1318,8 +1315,6 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
); );
case "polygon-datasets": case "polygon-datasets":
return <DatasetGatewayWorkspace />; return <DatasetGatewayWorkspace />;
case "polygon-run":
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
case "device": case "device":
return null; return null;
} }

View File

@ -12,6 +12,7 @@ let fetchGroundQualification;
let GroundQualificationContractError; let GroundQualificationContractError;
let parseGooseFrameId; let parseGooseFrameId;
let groupGooseReviewFrames; let groupGooseReviewFrames;
let analyzeGooseDatasetSequence;
const aggregate = { const aggregate = {
micro: { micro: {
@ -81,6 +82,7 @@ before(async () => {
parseGooseFrameId, parseGooseFrameId,
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts")); } = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
({ ({
analyzeGooseDatasetSequence,
groupGooseReviewFrames, groupGooseReviewFrames,
} = await server.ssrLoadModule("/src/workspaces/GooseDatasetReview.tsx")); } = 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); 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", () => { test("decodes a point-aligned review frame with intensity", () => {
const decoded = decodeGroundReviewFrame({ const decoded = decodeGroundReviewFrame({
schema_version: "missioncore.polygon-ground-review-frame/v1", schema_version: "missioncore.polygon-ground-review-frame/v1",

View File

@ -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"), { assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
active: true, active: true,
runId: null, runId: null,
@ -304,18 +304,27 @@ test("Polygon stays visible while worker capability gates live actions", () => {
error: null, error: null,
}, },
); );
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-datasets"), {
active: true,
runId: null,
error: null,
});
assert.equal(resolvePolygonRunRoute("?workspace=missions").active, false); assert.equal(resolvePolygonRunRoute("?workspace=missions").active, false);
assert.match( assert.match(
resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error, resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error,
/некорректный/, /некорректный/,
); );
assert.equal(workspaceById("polygon-run").root, "polygon"); assert.equal(workspaceById("polygon-run"), null);
assert.equal( assert.equal(
workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"), workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"),
true, false,
); );
assert.equal(workspaceById("polygon-datasets").root, "polygon"); assert.equal(workspaceById("polygon-datasets").root, "polygon");
assert.equal(workspaceById("polygon-datasets").kind, "polygon-datasets"); assert.equal(workspaceById("polygon-datasets").kind, "polygon-datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["polygon-datasets"],
);
assert.equal( assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"), workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
false, false,

View File

@ -191,9 +191,10 @@ The archival surface consumes the same append-only run repository through
does not create, chmod or mutate the configured repository and rejects every does not create, chmod or mutate the configured repository and rejects every
write transition. When `/api/v1/polygon/worker` returns an admitted available write transition. When `/api/v1/polygon/worker` returns an admitted available
worker, the Control Station adds `Полигон` as a seventh header root. One header worker, the Control Station adds `Полигон` as a seventh header root. One header
click opens the direct `polygon-run` workspace without a System launcher panel. click opens the single `polygon-datasets` workspace without a System launcher
The old `?workspace=polygon-run[&run=<id>]` URL remains a compatible deep-link panel. The old `?workspace=polygon-run[&run=<id>]` URL remains a compatibility
for review and exact-run selection, not the primary operator navigation. redirect into the dataset workspace; there is no separate Runs item in the
operator navigation.
Browser QA loaded the accepted archive and the S1C live worker, rotated and Browser QA loaded the accepted archive and the S1C live worker, rotated and
zoomed the 3D rover, observed live run/provider status, Gazebo sim time and zoomed the 3D rover, observed live run/provider status, Gazebo sim time and
ENU/FLU pose, then completed a clean stop. Start/stop stays behind the backend ENU/FLU pose, then completed a clean stop. Start/stop stays behind the backend

View File

@ -79,9 +79,9 @@ Polygon is now a parallel product branch. As of this document:
lifecycle gate; lifecycle gate;
- UI-2 exact generation `60e7916` established `Полигон` as a dedicated header - UI-2 exact generation `60e7916` established `Полигон` as a dedicated header
root only while Mission Core confirms an available Simulation Worker. The root only while Mission Core confirms an available Simulation Worker. The
current product surface keeps that direct entry and exposes a bounded current product surface keeps that direct entry. The operator navigation has
two-item navigation: `Прогоны` and `Датасеты`; it has no launcher or link one implemented item, `Датасеты`; immutable run evidence remains an internal
directory; backend contract rather than a second page;
- the browser-native Three.js scene renders a recognisable procedural Ackermann - the browser-native Three.js scene renders a recognisable procedural Ackermann
Rover, ground grid and ENU trajectory. Orbit supports 360° azimuth, bounded Rover, ground grid and ENU trajectory. Orbit supports 360° azimuth, bounded
vertical inspection from overhead to horizon, zoom, pan, follow and camera vertical inspection from overhead to horizon, zoom, pan, follow and camera
@ -845,46 +845,50 @@ The product surface and execution capability are intentionally distinct.
capability gates only live lifecycle and command actions, which fail closed and capability gates only live lifecycle and command actions, which fail closed and
show an explicit offline state. show an explicit offline state.
The Polygon navigation is task-oriented: The Polygon navigation is task-oriented and currently has one operator
surface:
- `Прогоны` owns selectable recorded simulation/dataset-replay/shadow history, - `Датасеты` owns admitted replay inputs, their real source structure,
aggregate outcomes, provenance and decisions; operator inspection of source scans and analysis scoped to the selected
- `Датасеты` owns admitted replay inputs, their real source structure and dataset fragment;
operator inspection of source scans. A selected run may be overlaid on the - immutable run identity, aggregate qualification, provenance and artifacts
source, but the source does not move into the run archive. remain available to backend evidence consumers but do not create a separate
operator page.
Dataset bytes and source artifacts remain in Data storage, but algorithm Dataset bytes and source artifacts remain in Data storage, but algorithm
evaluation does not live under `Данные`. Device-specific scanner diagnostics evaluation does not live under `Данные`. Device-specific scanner diagnostics
remain under Fleet. A dataset preview is not a qualification result; the remain under Fleet. The operator sees metrics in the context of the exact
result is a versioned run with pipeline, sensor profile, metrics, provenance fragment being inspected; global gates, provider pins and artifact identities
and decision. are not mixed into that workflow.
Live simulation is a different operator task. A rover scene, worker health and Live simulation is a different operator task. A rover scene, worker health and
start/control actions must not be mounted above an archived dataset replay. start/control actions must not be mounted above an archived dataset replay.
They require a separate live surface that appears only when the Simulation They require a separate future live surface that appears only when the
Worker capability is available. The current `Прогоны` implementation is Simulation Worker capability is available.
read-only and remains fully usable while that worker is offline.
### UI-0 — read-only run history and dataset inspection ### UI-0 — read-only dataset evidence inspection
Target S1B proves persisted start/health/stop history. The first direct Target S1B proves persisted start/health/stop history. The first direct
developer-oriented view exposed lifecycle, events, provider pins and artifact developer-oriented view exposed lifecycle, events, provider pins and artifact
hashes on the main screen. Operator review rejected that composition and then hashes on the main screen. Operator review rejected that composition, rejected
rejected treating the GOOSE validation split as a single recording. The treating the GOOSE validation split as a single recording and rejected a
current UI-0 follows two separate operator tasks: separate aggregate Runs page. The current UI-0 is one coherent dataset
workflow:
- `Прогоны` always shows a run selector, even when the archive currently has - `Датасеты → Открыть датасет` shows the admitted source. GOOSE validation is
only one item. The selected run exposes its kind (`simulation`, explicitly grouped into eight independent fragments; Play is bounded to one
`dataset-replay` or `shadow`), input identity, aggregate result and collapsed fragment and described as accelerated review of sparse annotated scans.
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, - The dataset viewer exposes source frame number, nanosecond timestamp gaps,
ground truth and optional Current/Patchwork++ overlays from a selected run. ground truth and Current/Patchwork++ overlays.
- The fixed sensor-centric coordinate frame and operator camera are preserved - The fixed sensor-centric coordinate frame and operator camera are preserved
within a sequence. Missing scans, ego trajectory and cross-sequence motion within a fragment. Missing scans, ego trajectory and cross-fragment motion
are never synthesized. are never synthesized.
- Below the viewer, metrics are calculated only across the selected fragment:
mean Ground IoU, natural-ground recall, obstacle preservation, improved and
regressed frame counts, weakest Patchwork++ frame, strongest regression and
largest capture gap. Every risk item opens its exact source frame.
- Global acceptance gates, run IDs, event journals, provider pins and artifact
lists are not part of the operator screen.
The accepted `3.3 GB` annotated validation ZIP is not a continuous recording. The accepted `3.3 GB` annotated validation ZIP is not a continuous recording.
Continuous ego-motion requires a separately admitted raw ROS bag and Continuous ego-motion requires a separately admitted raw ROS bag and
@ -907,7 +911,8 @@ fails closed. The review index exposes no paths or digests. A selected frame is
verified against the content-bound review manifest before its bounded point verified against the content-bound review manifest before its bounded point
preview is decoded. Responses never contain the configured root, raw dataset preview is decoded. Responses never contain the configured root, raw dataset
bytes or command payloads. `?workspace=polygon-run[&run=<run-id>]` remains a bytes or command payloads. `?workspace=polygon-run[&run=<run-id>]` remains a
compatible deep-link into the same product workspace. compatibility redirect into `polygon-datasets`; it does not restore a separate
Runs page.
UI-0 itself has no PX4 transport or lifecycle/command operations. It remains a UI-0 itself has no PX4 transport or lifecycle/command operations. It remains a
read-only evidence contract backed by the server-owned run repository and is read-only evidence contract backed by the server-owned run repository and is

View File

@ -65,18 +65,18 @@ The gateway is not part of device quality diagnostics.
dataset frames into the selected device evidence. dataset frames into the selected device evidence.
- **Наблюдение** opens a concrete live or recorded spatial scene. - **Наблюдение** opens a concrete live or recorded spatial scene.
- **Данные** owns source-of-record, retention, replay preparation and export. - **Данные** owns source-of-record, retention, replay preparation and export.
- **Полигон → Датасеты** lists admitted evaluation inputs and owns source - **Полигон → Датасеты** is the single operator surface for admitted evaluation
inspection: source sequences, sparse annotated scans and optional overlays inputs. Opening one dataset exposes its source fragments, sparse annotated
from a selected qualification run. scans, visual overlays and useful analysis calculated only for the selected
- **Полигон → Прогоны** owns the resulting algorithm comparison, metrics, fragment.
provenance and decision. It does not present the dataset itself as a - Internal replay-run identity, provenance and immutable artifacts remain a
simulation or continuous vehicle recording. backend evidence contract. They are not a second operator navigation item
and are not presented as vehicle motion.
Before real dataset bytes exist, the catalog explains the blocked storage gate Before real dataset bytes exist, the catalog explains the blocked storage gate
and keeps `Открыть` / `Создать прогон` disabled. `Открыть` becomes available and keeps `Открыть датасет` disabled. It becomes available
only after the worker manifest says `frame-ready` and the bounded preview passes only after the worker manifest says `frame-ready` and the bounded preview passes
its own point-alignment and safety checks. `Создать прогон` remains disabled its own point-alignment and safety checks.
until an executable comparison profile exists.
## Why public recordings look different ## Why public recordings look different

View File

@ -124,9 +124,10 @@ http://127.0.0.1:8765/
``` ```
After Mission Core confirms the worker, `Полигон` must appear in the header and After Mission Core confirms the worker, `Полигон` must appear in the header and
open the live workspace directly. The open the implemented Polygon workspace directly. The legacy
`?workspace=polygon-run[&run=<qualification-run-id>]` URL remains a compatible `?workspace=polygon-run[&run=<qualification-run-id>]` URL redirects to
deep-link. The live panel must show `Worker готов`. Start/stop calls require an `polygon-datasets`; it is not a separate operator surface. The live panel must
show `Worker готов`. Start/stop calls require an
`Idempotency-Key`; the React client generates one per operator intent. Removing `Idempotency-Key`; the React client generates one per operator intent. Removing
the `internal-virtual-only` environment gate must leave status visible while the `internal-virtual-only` environment gate must leave status visible while
disabling lifecycle actions. disabling lifecycle actions.