fix(polygon): unify dataset review workflow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user