fix(polygon): unify dataset review workflow
This commit is contained in:
parent
e97573e221
commit
13c35ff446
|
|
@ -150,7 +150,7 @@ function mergeViewerSettings(
|
|||
export default function App() {
|
||||
const runtime = useMissionRuntime();
|
||||
const { selection } = useDevicePluginHost();
|
||||
const polygonRunRoute = useMemo(
|
||||
const polygonDatasetRoute = useMemo(
|
||||
() => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search),
|
||||
[],
|
||||
);
|
||||
|
|
@ -160,7 +160,7 @@ export default function App() {
|
|||
});
|
||||
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||
polygonRunRoute.active ? "polygon" : null,
|
||||
polygonDatasetRoute.active ? "polygon" : null,
|
||||
);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
|
|
@ -194,7 +194,7 @@ export default function App() {
|
|||
const replayActiveRef = useRef(false);
|
||||
const sceneSettingsCommitterActiveRef = useRef(true);
|
||||
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
|
||||
const polygonRunRouteOpenedRef = useRef(false);
|
||||
const polygonDatasetRouteOpenedRef = useRef(false);
|
||||
|
||||
const currentRoot = rootById(activeRoot);
|
||||
const visibleRoots = roots;
|
||||
|
|
@ -214,10 +214,10 @@ export default function App() {
|
|||
replayActiveRef.current = replayActive;
|
||||
|
||||
useEffect(() => {
|
||||
if (!polygonRunRoute.active || polygonRunRouteOpenedRef.current) return;
|
||||
polygonRunRouteOpenedRef.current = true;
|
||||
workspace.openView("polygon-run");
|
||||
}, [polygonRunRoute.active, workspace]);
|
||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||
polygonDatasetRouteOpenedRef.current = true;
|
||||
workspace.openView("polygon-datasets");
|
||||
}, [polygonDatasetRoute.active, workspace]);
|
||||
|
||||
if (!sceneSettingsCommitterRef.current) {
|
||||
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
|
||||
|
|
@ -415,7 +415,7 @@ export default function App() {
|
|||
const selectRoot = (rootId: RootId) => {
|
||||
setActiveRoot(rootId);
|
||||
if (rootId === "polygon") {
|
||||
workspace.openView("polygon-run");
|
||||
workspace.openView("polygon-datasets");
|
||||
workspace.openNavigation();
|
||||
return;
|
||||
}
|
||||
|
|
@ -694,7 +694,12 @@ export default function App() {
|
|||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
<Icon name="activity" />
|
||||
</span>
|
||||
<span>{rootWorkspaces.length} рабочих поверхностей</span>
|
||||
<span>
|
||||
{rootWorkspaces.length}{" "}
|
||||
{rootWorkspaces.length === 1
|
||||
? "рабочая поверхность"
|
||||
: "рабочих поверхностей"}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
@ -740,8 +745,6 @@ export default function App() {
|
|||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : activeDefinition.kind === "polygon-run" ? (
|
||||
<StatusBadge tone="accent">Virtual only</StatusBadge>
|
||||
) : activeDefinition.kind === "polygon-datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : (
|
||||
|
|
@ -772,7 +775,6 @@ export default function App() {
|
|||
livePerceptionLayers={livePerceptionLayers}
|
||||
onLivePerceptionLayersChange={changeLivePerceptionLayers}
|
||||
observationLayout={observationLayout}
|
||||
polygonRunRoute={polygonRunRoute}
|
||||
deviceLabel={selection?.model.displayName ?? null}
|
||||
spatialControls={selection?.SpatialControlsView
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -552,7 +552,10 @@ export function decodePolygonRunDetail(payload: unknown): PolygonRunDetail {
|
|||
export function resolvePolygonRunRoute(search: string): PolygonRunRoute {
|
||||
const parameters = new URLSearchParams(search);
|
||||
const workspaceValues = parameters.getAll("workspace");
|
||||
if (workspaceValues.length !== 1 || workspaceValues[0] !== "polygon-run") {
|
||||
if (
|
||||
workspaceValues.length !== 1
|
||||
|| !["polygon-datasets", "polygon-run"].includes(workspaceValues[0])
|
||||
) {
|
||||
return { active: false, runId: null, error: null };
|
||||
}
|
||||
const runValues = parameters.getAll("run");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -200,7 +200,8 @@
|
|||
.polygon-review-lead,
|
||||
.polygon-review-player__header,
|
||||
.polygon-review-order,
|
||||
.polygon-review-summary > header {
|
||||
.polygon-review-summary > header,
|
||||
.dataset-fragment-analysis > header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -217,6 +218,16 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis__verdict {
|
||||
min-width: 0;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison,
|
||||
.dataset-fragment-risks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.polygon-run-picker,
|
||||
.dataset-result-select,
|
||||
.dataset-sequence-select,
|
||||
|
|
|
|||
|
|
@ -1441,6 +1441,151 @@
|
|||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--station-panel);
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis h3,
|
||||
.dataset-fragment-analysis p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis h3 {
|
||||
margin-top: 0.28rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis p {
|
||||
max-width: 48rem;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis__verdict {
|
||||
display: grid;
|
||||
min-width: 14rem;
|
||||
justify-items: end;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis__verdict strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-analysis__verdict span,
|
||||
.dataset-fragment-analysis__verdict small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 1fr) auto auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.78rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem 0.75rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison article > span,
|
||||
.dataset-fragment-comparison small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison article > div {
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison i {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison em {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
font-size: 0.58rem;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.dataset-fragment-comparison em[data-positive="true"] {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.dataset-fragment-risks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.32rem;
|
||||
}
|
||||
|
||||
.dataset-fragment-risks button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.18rem;
|
||||
border: 0;
|
||||
border-radius: 0.78rem;
|
||||
outline: 0;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: inherit;
|
||||
padding: 0.68rem 0.75rem;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dataset-fragment-risks button:hover,
|
||||
.dataset-fragment-risks button:focus-visible {
|
||||
background: rgb(255 255 255 / 0.075);
|
||||
}
|
||||
|
||||
.dataset-fragment-risks button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.dataset-fragment-risks span,
|
||||
.dataset-fragment-risks small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataset-fragment-risks strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.polygon-review-summary,
|
||||
.polygon-review-unavailable,
|
||||
.polygon-review-analysis,
|
||||
|
|
|
|||
|
|
@ -125,18 +125,17 @@ export function DatasetGatewayWorkspace() {
|
|||
<span className="section-eyebrow">ЗАЧЕМ ЭТО НУЖНО</span>
|
||||
<h2>Независимая проверка алгоритмов</h2>
|
||||
<p>
|
||||
Датасет — это вход квалификационного прогона. Мы подаём известные
|
||||
размеченные данные в выбранный pipeline, измеряем ошибки и сохраняем
|
||||
воспроизводимое решение. Он не подмешивается в диагностику
|
||||
реального сенсора и не
|
||||
заменяет closed-loop симуляцию.
|
||||
Датасет содержит известные размеченные данные. Мы показываем
|
||||
конкретный фрагмент, результаты каждого алгоритма и ошибки
|
||||
относительно разметки. Он не подмешивается в диагностику реального
|
||||
сенсора и не заменяет closed-loop симуляцию.
|
||||
</p>
|
||||
</div>
|
||||
<ol aria-label="Последовательность квалификации">
|
||||
<li><span>01</span>Выбрать данные</li>
|
||||
<li><span>02</span>Запустить pipeline</li>
|
||||
<li><span>03</span>Сравнить с разметкой</li>
|
||||
<li><span>04</span>Принять решение</li>
|
||||
<ol aria-label="Как использовать датасет">
|
||||
<li><span>01</span>Выбрать фрагмент</li>
|
||||
<li><span>02</span>Посмотреть сканы</li>
|
||||
<li><span>03</span>Сравнить алгоритмы</li>
|
||||
<li><span>04</span>Проверить риски</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
|
|
@ -179,9 +178,10 @@ export function DatasetGatewayWorkspace() {
|
|||
<span>PUBLIC · OFF-ROAD · LABELED</span>
|
||||
<h3>{catalog.source.displayName}</h3>
|
||||
<p>
|
||||
Validation split: восемь отдельных поездок и 961 разреженный
|
||||
размеченный оборот VLS-128. Это коллекция native scans для
|
||||
perception, а не непрерывное видео или траектория машины.
|
||||
Validation split: восемь фрагментов исходных записей и 961
|
||||
разреженный размеченный оборот VLS-128. Это коллекция native
|
||||
scans для perception, а не непрерывное видео или траектория
|
||||
машины.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -232,20 +232,12 @@ export function DatasetGatewayWorkspace() {
|
|||
disabled={catalog.source.admissionStatus !== "frame-ready"}
|
||||
title={
|
||||
catalog.source.admissionStatus === "frame-ready"
|
||||
? "Открыть все доступные последовательности и scans"
|
||||
: "Размеченные scans ещё не импортированы"
|
||||
? "Открыть фрагменты, сканы и результаты анализа"
|
||||
: "Размеченные сканы ещё не импортированы"
|
||||
}
|
||||
onClick={() => setPreviewOpen((value) => !value)}
|
||||
>
|
||||
{previewOpen ? "Закрыть источник" : "Открыть источник"}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
title="Открыть read-only архив квалификационных прогонов"
|
||||
onClick={() => window.location.assign("/?workspace=polygon-run")}
|
||||
>
|
||||
Открыть прогоны
|
||||
{previewOpen ? "Закрыть датасет" : "Открыть датасет"}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
@ -254,20 +246,20 @@ export function DatasetGatewayWorkspace() {
|
|||
|
||||
{previewOpen && selectedRunId ? (
|
||||
<>
|
||||
{gooseRuns.length > 0 ? (
|
||||
{gooseRuns.length > 1 ? (
|
||||
<div className="dataset-result-select">
|
||||
<div>
|
||||
<span className="section-eyebrow">НАЛОЖЕНИЕ РЕЗУЛЬТАТА</span>
|
||||
<strong>Квалификационный прогон</strong>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
|
||||
<strong>Версия анализа</strong>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Выбрать прогон для наложения"
|
||||
aria-label="Выбрать версию анализа"
|
||||
value={selectedRunId}
|
||||
onChange={(event) => setSelectedRunId(event.target.value)}
|
||||
>
|
||||
{gooseRuns.map((item) => (
|
||||
<option key={item.runId} value={item.runId}>
|
||||
{item.scenarioGeneration} · {item.runId}
|
||||
{item.profileGeneration}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -278,8 +270,8 @@ export function DatasetGatewayWorkspace() {
|
|||
) : previewOpen ? (
|
||||
<section className="polygon-review-unavailable">
|
||||
<StatusBadge tone="warning">Review недоступен</StatusBadge>
|
||||
<h3>Источник принят, но покадровый preview не опубликован</h3>
|
||||
<p>{runError ?? "Нет квалификационного прогона с bounded frame-pack."}</p>
|
||||
<h3>Датасет принят, но результаты анализа не опубликованы</h3>
|
||||
<p>{runError ?? "Нет совместимой версии покадрового анализа."}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,18 +31,37 @@ export interface GooseDatasetSequence {
|
|||
maximumGapSeconds: number;
|
||||
}
|
||||
|
||||
interface SequenceMetricComparison {
|
||||
current: number;
|
||||
patchwork: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface GooseDatasetSequenceAnalysis {
|
||||
groundIou: SequenceMetricComparison;
|
||||
naturalGroundRecall: SequenceMetricComparison;
|
||||
obstacleNonGroundRecall: SequenceMetricComparison;
|
||||
patchworkBetterFrames: number;
|
||||
currentBetterFrames: number;
|
||||
unchangedFrames: number;
|
||||
weakestPatchworkFrame: GroundReviewFrameSummary;
|
||||
worstRegressionFrame: GroundReviewFrameSummary;
|
||||
largestGapFrame: GroundReviewFrameSummary | null;
|
||||
largestGapSeconds: number;
|
||||
}
|
||||
|
||||
const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [
|
||||
["intensity", "Исходный скан"],
|
||||
["ground-truth", "Ground truth"],
|
||||
["current", "Current"],
|
||||
["current", "Текущий алгоритм"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Ошибки Current"],
|
||||
["disagreement", "Ошибки текущего"],
|
||||
["candidate-disagreement", "Ошибки Patchwork++"],
|
||||
];
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return "Не удалось открыть последовательности GOOSE.";
|
||||
return "Не удалось открыть фрагменты GOOSE.";
|
||||
}
|
||||
|
||||
function secondsBetween(
|
||||
|
|
@ -78,6 +97,83 @@ export function groupGooseReviewFrames(
|
|||
});
|
||||
}
|
||||
|
||||
function average(
|
||||
frames: GroundReviewFrameSummary[],
|
||||
value: (frame: GroundReviewFrameSummary) => number,
|
||||
): number {
|
||||
return frames.reduce((total, frame) => total + value(frame), 0) / frames.length;
|
||||
}
|
||||
|
||||
function comparison(
|
||||
frames: GroundReviewFrameSummary[],
|
||||
value: (frame: GroundReviewFrameSummary) => {
|
||||
current: number;
|
||||
patchwork: number;
|
||||
},
|
||||
): SequenceMetricComparison {
|
||||
const current = average(frames, (frame) => value(frame).current);
|
||||
const patchwork = average(frames, (frame) => value(frame).patchwork);
|
||||
return { current, patchwork, delta: patchwork - current };
|
||||
}
|
||||
|
||||
export function analyzeGooseDatasetSequence(
|
||||
sequence: GooseDatasetSequence,
|
||||
): GooseDatasetSequenceAnalysis {
|
||||
const first = sequence.frames[0];
|
||||
if (!first) {
|
||||
throw new Error("Фрагмент датасета не содержит сканов.");
|
||||
}
|
||||
let weakestPatchworkFrame = first;
|
||||
let worstRegressionFrame = first;
|
||||
let largestGapFrame: GroundReviewFrameSummary | null = null;
|
||||
let largestGapSeconds = 0;
|
||||
let patchworkBetterFrames = 0;
|
||||
let currentBetterFrames = 0;
|
||||
let unchangedFrames = 0;
|
||||
|
||||
sequence.frames.forEach((frame, index) => {
|
||||
if (frame.patchwork.groundIou < weakestPatchworkFrame.patchwork.groundIou) {
|
||||
weakestPatchworkFrame = frame;
|
||||
}
|
||||
if (frame.groundIouDelta < worstRegressionFrame.groundIouDelta) {
|
||||
worstRegressionFrame = frame;
|
||||
}
|
||||
if (frame.groundIouDelta > 0.000_001) patchworkBetterFrames += 1;
|
||||
else if (frame.groundIouDelta < -0.000_001) currentBetterFrames += 1;
|
||||
else unchangedFrames += 1;
|
||||
|
||||
if (index > 0) {
|
||||
const gapSeconds = secondsBetween(sequence.frames[index - 1], frame);
|
||||
if (gapSeconds > largestGapSeconds) {
|
||||
largestGapSeconds = gapSeconds;
|
||||
largestGapFrame = frame;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
groundIou: comparison(sequence.frames, (frame) => ({
|
||||
current: frame.current.groundIou,
|
||||
patchwork: frame.patchwork.groundIou,
|
||||
})),
|
||||
naturalGroundRecall: comparison(sequence.frames, (frame) => ({
|
||||
current: frame.current.naturalGroundRecall,
|
||||
patchwork: frame.patchwork.naturalGroundRecall,
|
||||
})),
|
||||
obstacleNonGroundRecall: comparison(sequence.frames, (frame) => ({
|
||||
current: frame.current.obstacleNonGroundRecall,
|
||||
patchwork: frame.patchwork.obstacleNonGroundRecall,
|
||||
})),
|
||||
patchworkBetterFrames,
|
||||
currentBetterFrames,
|
||||
unchangedFrames,
|
||||
weakestPatchworkFrame,
|
||||
worstRegressionFrame,
|
||||
largestGapFrame,
|
||||
largestGapSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function sequenceLabel(sequenceId: string): string {
|
||||
const date = sequenceId.slice(0, 10);
|
||||
const title = sequenceId.slice(11).replaceAll("_", " ");
|
||||
|
|
@ -130,9 +226,11 @@ function frameScene(frame: GroundReviewFrame | null) {
|
|||
function modeExplanation(mode: LidarGroundViewMode): string {
|
||||
if (mode === "intensity") return "Один sensor-frame оборот VLS-128; цвет — remission.";
|
||||
if (mode === "ground-truth") return "Публичная point-aligned разметка GOOSE.";
|
||||
if (mode === "current") return "Результат Current для этого исходного скана.";
|
||||
if (mode === "current") return "Результат текущего алгоритма для этого исходного скана.";
|
||||
if (mode === "candidate") return "Результат Patchwork++ для этого исходного скана.";
|
||||
if (mode === "disagreement") return "Красным отмечены ошибки Current относительно labels.";
|
||||
if (mode === "disagreement") {
|
||||
return "Красным отмечены ошибки текущего алгоритма относительно разметки.";
|
||||
}
|
||||
return "Красным отмечены ошибки Patchwork++ относительно labels.";
|
||||
}
|
||||
|
||||
|
|
@ -146,6 +244,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
const [playing, setPlaying] = useState(false);
|
||||
const [frameLoading, setFrameLoading] = useState(false);
|
||||
const frameCache = useRef(new Map<string, GroundReviewFrame>());
|
||||
const playerRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -175,6 +274,10 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
const selectedSequence = sequences.find(
|
||||
(sequence) => sequence.datasetSequenceId === selectedSequenceId,
|
||||
) ?? sequences[0] ?? null;
|
||||
const sequenceAnalysis = useMemo(
|
||||
() => selectedSequence ? analyzeGooseDatasetSequence(selectedSequence) : null,
|
||||
[selectedSequence],
|
||||
);
|
||||
const selectedPosition = Math.max(
|
||||
0,
|
||||
selectedSequence?.frames.findIndex((frame) => frame.frameId === selectedFrameId) ?? 0,
|
||||
|
|
@ -252,6 +355,18 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
setSelectedFrameId(selectedSequence.frames[bounded]?.frameId ?? null);
|
||||
};
|
||||
|
||||
const inspectFrame = (
|
||||
frame: GroundReviewFrameSummary | null,
|
||||
viewMode: LidarGroundViewMode,
|
||||
) => {
|
||||
if (!selectedSequence || !frame) return;
|
||||
selectPosition(selectedSequence.frames.indexOf(frame));
|
||||
setMode(viewMode);
|
||||
window.requestAnimationFrame(() => {
|
||||
playerRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
};
|
||||
|
||||
const selectSequence = (sequenceId: string) => {
|
||||
const sequence = sequences.find((candidate) => (
|
||||
candidate.datasetSequenceId === sequenceId
|
||||
|
|
@ -276,7 +391,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
return (
|
||||
<section className="polygon-review-unavailable">
|
||||
<StatusBadge tone="accent">Читаем индекс</StatusBadge>
|
||||
<h3>Открываем последовательности GOOSE</h3>
|
||||
<h3>Открываем фрагменты GOOSE</h3>
|
||||
<p>Загружаем только bounded preview, исходный архив остаётся на worker.</p>
|
||||
</section>
|
||||
);
|
||||
|
|
@ -286,23 +401,24 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
<section className="dataset-sequence-review" aria-label="Просмотр GOOSE validation">
|
||||
<header className="dataset-sequence-review__truth">
|
||||
<div>
|
||||
<span className="section-eyebrow">СТРУКТУРА ИСХОДНИКА</span>
|
||||
<h2>8 отдельных поездок, а не одно видео</h2>
|
||||
<span className="section-eyebrow">СОДЕРЖИМОЕ ДАТАСЕТА</span>
|
||||
<h2>8 отдельных фрагментов исходных записей</h2>
|
||||
<p>
|
||||
Validation ZIP содержит {review.frameCount} разреженных размеченных
|
||||
LiDAR-оборотов. Между ними нет непрерывной траектории машины. Play ниже —
|
||||
ускоренный просмотр выбранной поездки, а не воспроизведение реального времени.
|
||||
LiDAR-оборотов. Каждый фрагмент относится к отдельному эпизоду съёмки.
|
||||
Play ниже — ускоренный просмотр доступных сканов выбранного фрагмента,
|
||||
а не воспроизведение движения машины в реальном времени.
|
||||
</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Последовательностей</dt><dd>{sequences.length}</dd></div>
|
||||
<div><dt>Фрагментов</dt><dd>{sequences.length}</dd></div>
|
||||
<div><dt>Размеченных сканов</dt><dd>{review.frameCount}</dd></div>
|
||||
<div><dt>Preview</dt><dd>{review.previewPoints.toLocaleString("ru-RU")} точек</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<div className="dataset-sequence-select">
|
||||
<label htmlFor="goose-sequence">Последовательность</label>
|
||||
<label htmlFor="goose-sequence">Фрагмент датасета</label>
|
||||
<select
|
||||
id="goose-sequence"
|
||||
value={selectedSequence.datasetSequenceId}
|
||||
|
|
@ -314,19 +430,19 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
value={sequence.datasetSequenceId}
|
||||
>
|
||||
{sequenceLabel(sequence.datasetSequenceId)}
|
||||
{" · "}{sequence.frames.length} scans
|
||||
{" · "}{sequence.frames.length} сканов
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>
|
||||
{selectedSequence.frames.length} размеченных сканов · интервал{" "}
|
||||
{selectedSequence.frames.length} сканов · интервалы{" "}
|
||||
{formatGap(selectedSequence.minimumGapSeconds)}–{
|
||||
formatGap(selectedSequence.maximumGapSeconds)
|
||||
} · охват {formatDuration(selectedSequence.durationSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="polygon-review-player">
|
||||
<section className="polygon-review-player" ref={playerRef}>
|
||||
<header className="polygon-review-player__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
|
|
@ -352,7 +468,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
<div className="polygon-review-controls">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={playing ? "Пауза" : "Воспроизвести последовательность"}
|
||||
aria-label={playing ? "Пауза" : "Воспроизвести фрагмент"}
|
||||
onClick={() => setPlaying((value) => !value)}
|
||||
>
|
||||
{playing ? "Пауза" : "Play review"}
|
||||
|
|
@ -378,7 +494,7 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
min={0}
|
||||
max={Math.max(0, selectedSequence.frames.length - 1)}
|
||||
value={selectedPosition}
|
||||
aria-label="Позиция в последовательности"
|
||||
aria-label="Позиция во фрагменте"
|
||||
onChange={(event) => selectPosition(Number(event.target.value))}
|
||||
/>
|
||||
<span>{selectedPosition + 1} / {selectedSequence.frames.length}</span>
|
||||
|
|
@ -411,26 +527,26 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
<small>реальный timestamp, не cadence playback</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Current · Ground IoU</span>
|
||||
<span>Текущий алгоритм · Ground IoU</span>
|
||||
<strong>{formatPercent(selectedSummary.current.groundIou)}</strong>
|
||||
<small>overlay выбранного прогона</small>
|
||||
<small>только выбранный скан</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Patchwork++ · Ground IoU</span>
|
||||
<strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong>
|
||||
<small>overlay выбранного прогона</small>
|
||||
<small>только выбранный скан</small>
|
||||
</article>
|
||||
<article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}>
|
||||
<span>Разница</span>
|
||||
<strong>{formatDelta(selectedSummary.groundIouDelta)}</strong>
|
||||
<small>Patchwork++ минус Current</small>
|
||||
<small>Patchwork++ минус текущий алгоритм</small>
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="polygon-review-timeline dataset-sequence-timeline"
|
||||
aria-label={`Все ${selectedSequence.frames.length} сканов последовательности`}
|
||||
aria-label={`Все ${selectedSequence.frames.length} сканов фрагмента`}
|
||||
>
|
||||
{selectedSequence.frames.map((frame, index) => (
|
||||
<button
|
||||
|
|
@ -445,11 +561,110 @@ export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
|
|||
))}
|
||||
</div>
|
||||
<p className="polygon-review-timeline-note">
|
||||
Риски показывают только доступные размеченные scans этой поездки.
|
||||
Пропуски времени не интерполируются; ракурс сохраняется между соседними scans.
|
||||
Полоса показывает только доступные размеченные сканы этого фрагмента.
|
||||
Пропуски времени не интерполируются; ракурс сохраняется между соседними сканами.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{sequenceAnalysis ? (
|
||||
<section
|
||||
className="dataset-fragment-analysis"
|
||||
aria-label="Результаты выбранного фрагмента"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ЭТОГО ФРАГМЕНТА</span>
|
||||
<h3>Что изменилось после Patchwork++</h3>
|
||||
<p>
|
||||
Средние значения рассчитаны только по{" "}
|
||||
{selectedSequence.frames.length} сканам выбранного фрагмента.
|
||||
Они не смешиваются с остальными частями датасета.
|
||||
</p>
|
||||
</div>
|
||||
<div className="dataset-fragment-analysis__verdict">
|
||||
<strong>{formatDelta(sequenceAnalysis.groundIou.delta)}</strong>
|
||||
<span>средний Ground IoU</span>
|
||||
<small>
|
||||
лучше на {sequenceAnalysis.patchworkBetterFrames} · хуже на{" "}
|
||||
{sequenceAnalysis.currentBetterFrames} · без изменения на{" "}
|
||||
{sequenceAnalysis.unchangedFrames}
|
||||
</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="dataset-fragment-comparison">
|
||||
{([
|
||||
["Ground IoU", sequenceAnalysis.groundIou],
|
||||
["Natural ground recall", sequenceAnalysis.naturalGroundRecall],
|
||||
["Obstacle preservation", sequenceAnalysis.obstacleNonGroundRecall],
|
||||
] as const).map(([label, metric]) => (
|
||||
<article key={label}>
|
||||
<span>{label}</span>
|
||||
<div>
|
||||
<small>Текущий</small>
|
||||
<strong>{formatPercent(metric.current)}</strong>
|
||||
</div>
|
||||
<i>→</i>
|
||||
<div>
|
||||
<small>Patchwork++</small>
|
||||
<strong>{formatPercent(metric.patchwork)}</strong>
|
||||
</div>
|
||||
<em data-positive={metric.delta >= 0 ? "true" : "false"}>
|
||||
{formatDelta(metric.delta)}
|
||||
</em>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dataset-fragment-risks">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inspectFrame(
|
||||
sequenceAnalysis.worstRegressionFrame,
|
||||
"candidate-disagreement",
|
||||
)}
|
||||
>
|
||||
<span>Самое сильное ухудшение</span>
|
||||
<strong>{formatDelta(sequenceAnalysis.worstRegressionFrame.groundIouDelta)}</strong>
|
||||
<small>
|
||||
source frame {sequenceAnalysis.worstRegressionFrame.datasetFrameNumber}
|
||||
</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inspectFrame(
|
||||
sequenceAnalysis.weakestPatchworkFrame,
|
||||
"candidate",
|
||||
)}
|
||||
>
|
||||
<span>Самый слабый результат Patchwork++</span>
|
||||
<strong>
|
||||
{formatPercent(sequenceAnalysis.weakestPatchworkFrame.patchwork.groundIou)}
|
||||
</strong>
|
||||
<small>
|
||||
source frame {sequenceAnalysis.weakestPatchworkFrame.datasetFrameNumber}
|
||||
</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!sequenceAnalysis.largestGapFrame}
|
||||
onClick={() => inspectFrame(
|
||||
sequenceAnalysis.largestGapFrame,
|
||||
"intensity",
|
||||
)}
|
||||
>
|
||||
<span>Самый большой пропуск записи</span>
|
||||
<strong>{formatGap(sequenceAnalysis.largestGapSeconds)}</strong>
|
||||
<small>
|
||||
{sequenceAnalysis.largestGapFrame
|
||||
? `перед source frame ${sequenceAnalysis.largestGapFrame.datasetFrameNumber}`
|
||||
: "фрагмент содержит один скан"}
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<p className="dataset-sequence-review__raw-note">
|
||||
Для настоящего движения машины, odometry и синхронного sensor playback нужен
|
||||
отдельный raw ROS bag GOOSE с localization. Он не входит в принятый 3.3 ГБ
|
||||
|
|
|
|||
|
|
@ -1,374 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Button, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchGroundQualification,
|
||||
type GroundQualification,
|
||||
} from "../core/polygon/groundQualification";
|
||||
import {
|
||||
fetchPolygonRunCatalog,
|
||||
fetchPolygonRunDetail,
|
||||
type PolygonRunCatalog,
|
||||
type PolygonRunDetail,
|
||||
type PolygonRunRoute,
|
||||
type PolygonRunState,
|
||||
} from "../core/polygon/runArchive";
|
||||
|
||||
interface PolygonRunWorkspaceProps {
|
||||
route: PolygonRunRoute;
|
||||
}
|
||||
|
||||
const stateLabels: Record<PolygonRunState, string> = {
|
||||
admitted: "Допущен",
|
||||
starting: "Запускается",
|
||||
running: "Выполняется",
|
||||
paused: "На паузе",
|
||||
stopping: "Останавливается",
|
||||
completed: "Завершён",
|
||||
failed: "Ошибка",
|
||||
aborted: "Прерван",
|
||||
};
|
||||
|
||||
function stateTone(
|
||||
state: PolygonRunState,
|
||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||
if (state === "completed") return "success";
|
||||
if (state === "running") return "accent";
|
||||
if (state === "failed" || state === "aborted") return "danger";
|
||||
if (state === "starting" || state === "stopping" || state === "paused") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function runKindLabel(kind: string): string {
|
||||
if (kind === "simulation-closed-loop" || kind === "simulation_closed_loop") {
|
||||
return "Closed-loop simulation";
|
||||
}
|
||||
if (kind === "dataset-replay" || kind === "replay_shadow") {
|
||||
return "Dataset replay";
|
||||
}
|
||||
if (kind === "shadow") return "Live shadow";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value === 0) return "0 Б";
|
||||
const units = ["Б", "КБ", "МБ", "ГБ"];
|
||||
const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / 1024 ** exponent).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDelta(value: number): string {
|
||||
const points = value * 100;
|
||||
return `${points >= 0 ? "+" : ""}${points.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} п.п.`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return "Не удалось прочитать прогон.";
|
||||
}
|
||||
|
||||
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
|
||||
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
|
||||
const [qualification, setQualification] = useState<GroundQualification | null>(null);
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(route.runId);
|
||||
const [loading, setLoading] = useState(route.error === null);
|
||||
const [error, setError] = useState<string | null>(route.error);
|
||||
const [reloadGeneration, setReloadGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (route.error) {
|
||||
setLoading(false);
|
||||
setError(route.error);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(nextCatalog);
|
||||
const targetRunId = selectedRunId
|
||||
?? route.runId
|
||||
?? nextCatalog.items[0]?.runId
|
||||
?? null;
|
||||
if (!targetRunId) {
|
||||
setDetail(null);
|
||||
setQualification(null);
|
||||
return;
|
||||
}
|
||||
const nextDetail = await fetchPolygonRunDetail(targetRunId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
const nextQualification = await fetchGroundQualification(targetRunId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setSelectedRunId(targetRunId);
|
||||
setDetail(nextDetail);
|
||||
setQualification(nextQualification);
|
||||
} catch (loadError) {
|
||||
if (controller.signal.aborted) return;
|
||||
setDetail(null);
|
||||
setQualification(null);
|
||||
setError(errorMessage(loadError));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [reloadGeneration, route.error, route.runId, selectedRunId]);
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<section className="polygon-run-message">
|
||||
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
|
||||
<h2>Открываем историю прогонов</h2>
|
||||
<p>Читаем каталог и выбранный воспроизводимый запуск.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<section className="polygon-run-message">
|
||||
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
|
||||
<h2>Не удалось открыть прогон</h2>
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<section className="polygon-run-message">
|
||||
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
|
||||
<h2>Прогонов пока нет</h2>
|
||||
<p>Здесь появятся simulation, dataset replay и shadow-запуски.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { run } = detail;
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<section className="polygon-run-picker">
|
||||
<div>
|
||||
<span className="section-eyebrow">ИСТОРИЯ ПОЛИГОНА</span>
|
||||
<strong>Выбранный прогон</strong>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Выбрать прогон"
|
||||
value={run.runId}
|
||||
disabled={!catalog?.items.length}
|
||||
onChange={(event) => setSelectedRunId(event.target.value)}
|
||||
>
|
||||
{(catalog?.items ?? []).map((item) => (
|
||||
<option key={item.runId} value={item.runId}>
|
||||
{runKindLabel(item.kind)} · {item.scenarioGeneration} · {item.runId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>{catalog?.total ?? 0} в архиве</small>
|
||||
</section>
|
||||
|
||||
<section className="polygon-review-lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">{runKindLabel(run.kind).toUpperCase()}</span>
|
||||
<h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
|
||||
<p>
|
||||
{qualification
|
||||
? "Результат алгоритмического replay. Сам исходный датасет и его sequences открываются в разделе «Датасеты»."
|
||||
: "Воспроизводимая запись запуска Полигона."}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
|
||||
</section>
|
||||
|
||||
{qualification ? (
|
||||
<>
|
||||
<section className="polygon-run-input">
|
||||
<div>
|
||||
<span className="section-eyebrow">ВХОД ПРОГОНА</span>
|
||||
<h3>GOOSE 3D · validation</h3>
|
||||
<p>
|
||||
Восемь отдельных поездок, 961 разреженный размеченный LiDAR scan.
|
||||
Это не closed-loop simulation и не непрерывная запись движения машины.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => window.location.assign(
|
||||
`/?workspace=polygon-datasets&run=${encodeURIComponent(run.runId)}`,
|
||||
)}
|
||||
>
|
||||
Открыть исходник
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="polygon-review-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ ПРОГОНА</span>
|
||||
<h3>Current против Patchwork++</h3>
|
||||
</div>
|
||||
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
|
||||
{qualification.decision.passed ? "27 / 27 проверок" : "Есть провалы"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<p>
|
||||
Оба алгоритма независимо обработали одни и те же 961 scans.
|
||||
Покадровая визуальная проверка теперь находится рядом с самим входом
|
||||
в «Датасетах», а не притворяется симуляцией машины.
|
||||
</p>
|
||||
<div>
|
||||
<article>
|
||||
<span>Ground IoU</span>
|
||||
<strong>{formatPercent(qualification.current.micro.groundIou)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatPercent(qualification.patchwork.micro.groundIou)}</strong>
|
||||
<small>{formatDelta(
|
||||
qualification.patchwork.micro.groundIou
|
||||
- qualification.current.micro.groundIou,
|
||||
)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Natural ground recall</span>
|
||||
<strong>{formatPercent(
|
||||
qualification.current.micro.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatPercent(
|
||||
qualification.patchwork.micro.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<small>{formatDelta(
|
||||
qualification.patchwork.micro.naturalGroundRecall
|
||||
- qualification.current.micro.naturalGroundRecall,
|
||||
)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Latency p95</span>
|
||||
<strong>{qualification.current.latencyMs.p95.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} мс</strong>
|
||||
<i>→</i>
|
||||
<strong>{qualification.patchwork.latencyMs.p95.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} мс</strong>
|
||||
<small>на scan</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details className="polygon-review-analysis">
|
||||
<summary>Acceptance и деградации</summary>
|
||||
<div className="polygon-review-analysis__content">
|
||||
<section>
|
||||
<span className="section-eyebrow">ACCEPTANCE GATES</span>
|
||||
{qualification.checks.map((check) => (
|
||||
<p key={check.checkId} data-passed={check.passed ? "true" : "false"}>
|
||||
<i aria-hidden="true" />
|
||||
<span>{check.checkId}</span>
|
||||
<code>
|
||||
{check.observed.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
|
||||
{" "}{check.operator}{" "}
|
||||
{check.threshold.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
|
||||
</code>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<section>
|
||||
<span className="section-eyebrow">ДЕГРАДАЦИИ</span>
|
||||
{Object.entries(qualification.degradations).map(([profileId, aggregate]) => (
|
||||
<p key={profileId}>
|
||||
<strong>{profileId}</strong>
|
||||
<span>IoU {formatPercent(aggregate.micro.groundIou)}</span>
|
||||
<span>Natural {formatPercent(aggregate.micro.naturalGroundRecall)}</span>
|
||||
<span>p95 {aggregate.latencyMs.p95.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} мс</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<section className="polygon-review-unavailable">
|
||||
<span className="section-eyebrow">АРХИВНЫЙ ПРОГОН</span>
|
||||
<h3>Для этого запуска нет perception qualification</h3>
|
||||
<p>Техническая идентичность доступна ниже.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<details className="polygon-run-technical">
|
||||
<summary>Технические детали прогона</summary>
|
||||
<div className="polygon-run-technical__content">
|
||||
<dl>
|
||||
<div><dt>Run ID</dt><dd>{run.runId}</dd></div>
|
||||
<div><dt>Тип</dt><dd>{runKindLabel(run.kind)}</dd></div>
|
||||
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
|
||||
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
|
||||
<div><dt>Mission Core</dt><dd>{run.missionCoreCommit.slice(0, 12)}</dd></div>
|
||||
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
|
||||
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
|
||||
</dl>
|
||||
<section>
|
||||
<h4>Провайдеры</h4>
|
||||
{run.providers.map((provider) => (
|
||||
<p key={provider.identifier}>
|
||||
<strong>{provider.identifier}</strong>
|
||||
<span>{provider.version} · {provider.revision.slice(0, 16)}</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<section>
|
||||
<h4>Артефакты</h4>
|
||||
{detail.artifacts.map((artifact) => (
|
||||
<p key={artifact.artifactId}>
|
||||
<strong>{artifact.kind}</strong>
|
||||
<span>{formatBytes(artifact.byteLength)} · {artifact.sha256.slice(0, 12)}</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ import {
|
|||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { PolygonRunRoute } from "../core/polygon/runArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
|
|
@ -56,7 +55,6 @@ import {
|
|||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
|
||||
import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
|
||||
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
|
||||
|
|
@ -140,7 +138,6 @@ export interface WorkspaceRendererProps {
|
|||
cuboids3d: boolean;
|
||||
}) => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
polygonRunRoute: PolygonRunRoute;
|
||||
deviceLabel: string | null;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
|
|
@ -1318,8 +1315,6 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
|||
);
|
||||
case "polygon-datasets":
|
||||
return <DatasetGatewayWorkspace />;
|
||||
case "polygon-run":
|
||||
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ let fetchGroundQualification;
|
|||
let GroundQualificationContractError;
|
||||
let parseGooseFrameId;
|
||||
let groupGooseReviewFrames;
|
||||
let analyzeGooseDatasetSequence;
|
||||
|
||||
const aggregate = {
|
||||
micro: {
|
||||
|
|
@ -81,6 +82,7 @@ before(async () => {
|
|||
parseGooseFrameId,
|
||||
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
|
||||
({
|
||||
analyzeGooseDatasetSequence,
|
||||
groupGooseReviewFrames,
|
||||
} = await server.ssrLoadModule("/src/workspaces/GooseDatasetReview.tsx"));
|
||||
});
|
||||
|
|
@ -230,6 +232,70 @@ test("keeps independent GOOSE recordings in separate playback sequences", () =>
|
|||
assert.ok(groups[0].durationSeconds > 1.6);
|
||||
});
|
||||
|
||||
test("summarizes only the selected dataset fragment", () => {
|
||||
const frames = [
|
||||
{
|
||||
sequence: 0,
|
||||
frameId: "2022-07-22_flight__0071_1658494234334310308",
|
||||
datasetSequenceId: "2022-07-22_flight",
|
||||
datasetFrameNumber: 71,
|
||||
sensorTimestampNs: "1658494234334310308",
|
||||
sourcePointCount: 100_000,
|
||||
pointCount: 12_000,
|
||||
current: {
|
||||
groundIou: 0.4,
|
||||
naturalGroundRecall: 0.5,
|
||||
obstacleNonGroundRecall: 0.9,
|
||||
latencyMs: 100,
|
||||
},
|
||||
patchwork: {
|
||||
groundIou: 0.6,
|
||||
naturalGroundRecall: 0.7,
|
||||
obstacleNonGroundRecall: 0.95,
|
||||
latencyMs: 20,
|
||||
},
|
||||
groundIouDelta: 0.2,
|
||||
},
|
||||
{
|
||||
sequence: 1,
|
||||
frameId: "2022-07-22_flight__0072_1658494244334310308",
|
||||
datasetSequenceId: "2022-07-22_flight",
|
||||
datasetFrameNumber: 72,
|
||||
sensorTimestampNs: "1658494244334310308",
|
||||
sourcePointCount: 100_000,
|
||||
pointCount: 12_000,
|
||||
current: {
|
||||
groundIou: 0.7,
|
||||
naturalGroundRecall: 0.8,
|
||||
obstacleNonGroundRecall: 0.94,
|
||||
latencyMs: 100,
|
||||
},
|
||||
patchwork: {
|
||||
groundIou: 0.6,
|
||||
naturalGroundRecall: 0.75,
|
||||
obstacleNonGroundRecall: 0.93,
|
||||
latencyMs: 20,
|
||||
},
|
||||
groundIouDelta: -0.1,
|
||||
},
|
||||
];
|
||||
const analysis = analyzeGooseDatasetSequence({
|
||||
datasetSequenceId: "2022-07-22_flight",
|
||||
frames,
|
||||
durationSeconds: 10,
|
||||
minimumGapSeconds: 10,
|
||||
maximumGapSeconds: 10,
|
||||
});
|
||||
assert.equal(analysis.groundIou.current, 0.55);
|
||||
assert.equal(analysis.groundIou.patchwork, 0.6);
|
||||
assert.ok(Math.abs(analysis.groundIou.delta - 0.05) < 1e-12);
|
||||
assert.equal(analysis.patchworkBetterFrames, 1);
|
||||
assert.equal(analysis.currentBetterFrames, 1);
|
||||
assert.equal(analysis.worstRegressionFrame.datasetFrameNumber, 72);
|
||||
assert.equal(analysis.largestGapFrame.datasetFrameNumber, 72);
|
||||
assert.equal(analysis.largestGapSeconds, 10);
|
||||
});
|
||||
|
||||
test("decodes a point-aligned review frame with intensity", () => {
|
||||
const decoded = decodeGroundReviewFrame({
|
||||
schema_version: "missioncore.polygon-ground-review-frame/v1",
|
||||
|
|
|
|||
|
|
@ -290,7 +290,7 @@ function commandAcceptance(overrides = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
test("Polygon stays visible while worker capability gates live actions", () => {
|
||||
test("Polygon exposes one dataset surface and keeps legacy links compatible", () => {
|
||||
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
|
||||
active: true,
|
||||
runId: null,
|
||||
|
|
@ -304,18 +304,27 @@ test("Polygon stays visible while worker capability gates live actions", () => {
|
|||
error: null,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-datasets"), {
|
||||
active: true,
|
||||
runId: null,
|
||||
error: null,
|
||||
});
|
||||
assert.equal(resolvePolygonRunRoute("?workspace=missions").active, false);
|
||||
assert.match(
|
||||
resolvePolygonRunRoute("?workspace=polygon-run&run=../../etc").error,
|
||||
/некорректный/,
|
||||
);
|
||||
assert.equal(workspaceById("polygon-run").root, "polygon");
|
||||
assert.equal(workspaceById("polygon-run"), null);
|
||||
assert.equal(
|
||||
workspacesForRoot("polygon").some(({ id }) => id === "polygon-run"),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert.equal(workspaceById("polygon-datasets").root, "polygon");
|
||||
assert.equal(workspaceById("polygon-datasets").kind, "polygon-datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["polygon-datasets"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue