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() {
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
? {

View File

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

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",

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,

View File

@ -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,

View File

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

View File

@ -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 ГБ

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 { 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;
}

View File

@ -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",

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"), {
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,

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
write transition. When `/api/v1/polygon/worker` returns an admitted available
worker, the Control Station adds `Полигон` as a seventh header root. One header
click opens the direct `polygon-run` workspace without a System launcher panel.
The old `?workspace=polygon-run[&run=<id>]` URL remains a compatible deep-link
for review and exact-run selection, not the primary operator navigation.
click opens the single `polygon-datasets` workspace without a System launcher
panel. The old `?workspace=polygon-run[&run=<id>]` URL remains a compatibility
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
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

View File

@ -79,9 +79,9 @@ Polygon is now a parallel product branch. As of this document:
lifecycle gate;
- UI-2 exact generation `60e7916` established `Полигон` as a dedicated header
root only while Mission Core confirms an available Simulation Worker. The
current product surface keeps that direct entry and exposes a bounded
two-item navigation: `Прогоны` and `Датасеты`; it has no launcher or link
directory;
current product surface keeps that direct entry. The operator navigation has
one implemented item, `Датасеты`; immutable run evidence remains an internal
backend contract rather than a second page;
- the browser-native Three.js scene renders a recognisable procedural Ackermann
Rover, ground grid and ENU trajectory. Orbit supports 360° azimuth, bounded
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
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,
aggregate outcomes, provenance and decisions;
- `Датасеты` owns admitted replay inputs, their real source structure and
operator inspection of source scans. A selected run may be overlaid on the
source, but the source does not move into the run archive.
- `Датасеты` owns admitted replay inputs, their real source structure,
operator inspection of source scans and analysis scoped to the selected
dataset fragment;
- immutable run identity, aggregate qualification, provenance and artifacts
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
evaluation does not live under `Данные`. Device-specific scanner diagnostics
remain under Fleet. A dataset preview is not a qualification result; the
result is a versioned run with pipeline, sensor profile, metrics, provenance
and decision.
remain under Fleet. The operator sees metrics in the context of the exact
fragment being inspected; global gates, provider pins and artifact identities
are not mixed into that workflow.
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.
They require a separate live surface that appears only when the Simulation
Worker capability is available. The current `Прогоны` implementation is
read-only and remains fully usable while that worker is offline.
They require a separate future live surface that appears only when the
Simulation Worker capability is available.
### 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
developer-oriented view exposed lifecycle, events, provider pins and artifact
hashes on the main screen. Operator review rejected that composition and then
rejected treating the GOOSE validation split as a single recording. The
current UI-0 follows two separate operator tasks:
hashes on the main screen. Operator review rejected that composition, rejected
treating the GOOSE validation split as a single recording and rejected a
separate aggregate Runs page. The current UI-0 is one coherent dataset
workflow:
- `Прогоны` always shows a run selector, even when the archive currently has
only one item. The selected run exposes its kind (`simulation`,
`dataset-replay` or `shadow`), input identity, aggregate result and collapsed
technical evidence.
- `Датасеты → Открыть источник` shows the admitted source. GOOSE validation is
explicitly grouped into eight independent sequences; Play is bounded to one
sequence and described as accelerated review of sparse annotated scans.
- `Датасеты → Открыть датасет` shows the admitted source. GOOSE validation is
explicitly grouped into eight independent fragments; Play is bounded to one
fragment and described as accelerated review of sparse annotated scans.
- The dataset viewer exposes source frame number, nanosecond timestamp gaps,
ground truth and optional Current/Patchwork++ overlays from a selected run.
ground truth and Current/Patchwork++ overlays.
- 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.
- 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.
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
preview is decoded. Responses never contain the configured root, raw dataset
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
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.
- **Наблюдение** opens a concrete live or recorded spatial scene.
- **Данные** owns source-of-record, retention, replay preparation and export.
- **Полигон → Датасеты** lists admitted evaluation inputs and owns source
inspection: source sequences, sparse annotated scans and optional overlays
from a selected qualification run.
- **Полигон → Прогоны** owns the resulting algorithm comparison, metrics,
provenance and decision. It does not present the dataset itself as a
simulation or continuous vehicle recording.
- **Полигон → Датасеты** is the single operator surface for admitted evaluation
inputs. Opening one dataset exposes its source fragments, sparse annotated
scans, visual overlays and useful analysis calculated only for the selected
fragment.
- Internal replay-run identity, provenance and immutable artifacts remain a
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
and keeps `Открыть` / `Создать прогон` disabled. `Открыть` becomes available
and keeps `Открыть датасет` disabled. It becomes available
only after the worker manifest says `frame-ready` and the bounded preview passes
its own point-alignment and safety checks. `Создать прогон` remains disabled
until an executable comparison profile exists.
its own point-alignment and safety checks.
## 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
open the live workspace directly. The
`?workspace=polygon-run[&run=<qualification-run-id>]` URL remains a compatible
deep-link. The live panel must show `Worker готов`. Start/stop calls require an
open the implemented Polygon workspace directly. The legacy
`?workspace=polygon-run[&run=<qualification-run-id>]` URL redirects to
`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
the `internal-virtual-only` environment gate must leave status visible while
disabling lifecycle actions.