feat: qualify complete RELLIS ground dataset
This commit is contained in:
@@ -41,6 +41,7 @@ function admissionLabel(status: DatasetSourceAdmissionStatus): string {
|
||||
verified: "Проверен",
|
||||
"frame-ready": "Dataset готов",
|
||||
"smoke-ready": "Smoke готов",
|
||||
"dataset-ready": "Dataset готов",
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
@@ -60,9 +61,17 @@ function isGooseQualificationRun(
|
||||
&& item.scenarioGeneration.startsWith("goose-3d");
|
||||
}
|
||||
|
||||
function isRellisQualificationRun(
|
||||
item: PolygonRunCatalog["items"][number],
|
||||
): boolean {
|
||||
return item.kind === "replay_shadow"
|
||||
&& item.scenarioGeneration.startsWith("rellis-3d");
|
||||
}
|
||||
|
||||
function sourceReady(source: DatasetSource): boolean {
|
||||
return source.admissionStatus === "frame-ready"
|
||||
|| source.admissionStatus === "smoke-ready";
|
||||
|| source.admissionStatus === "smoke-ready"
|
||||
|| source.admissionStatus === "dataset-ready";
|
||||
}
|
||||
|
||||
function sourceDescription(source: DatasetSource): string {
|
||||
@@ -76,6 +85,9 @@ function sourceFooter(source: DatasetSource, storageReady: boolean): string {
|
||||
if (source.admissionStatus === "frame-ready") {
|
||||
return "Native scans и разметка приняты. Можно открыть фрагменты и результаты алгоритмов.";
|
||||
}
|
||||
if (source.admissionStatus === "dataset-ready") {
|
||||
return "Все официальные scans, labels, poses и split-индексы выровнены на worker D.";
|
||||
}
|
||||
if (source.admissionStatus === "smoke-ready") {
|
||||
return "Официальный пример совместим. Можно проверить scan, классы и ground mapping.";
|
||||
}
|
||||
@@ -98,6 +110,7 @@ export function DatasetGatewayWorkspace() {
|
||||
const [openSourceId, setOpenSourceId] = useState<string | null>(null);
|
||||
const [runCatalog, setRunCatalog] = useState<PolygonRunCatalog | null>(null);
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [selectedRellisRunId, setSelectedRellisRunId] = useState<string | null>(null);
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -143,6 +156,11 @@ export function DatasetGatewayWorkspace() {
|
||||
?? gooseRuns[0]
|
||||
?? null;
|
||||
setSelectedRunId(selected?.runId ?? null);
|
||||
const rellisRuns = value.items.filter(isRellisQualificationRun);
|
||||
const selectedRellis = rellisRuns.find((item) => item.runId === requested)
|
||||
?? rellisRuns[0]
|
||||
?? null;
|
||||
setSelectedRellisRunId(selectedRellis?.runId ?? null);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setRunError(errorMessage(loadError));
|
||||
@@ -151,6 +169,7 @@ export function DatasetGatewayWorkspace() {
|
||||
}, []);
|
||||
|
||||
const gooseRuns = runCatalog?.items.filter(isGooseQualificationRun) ?? [];
|
||||
const rellisRuns = runCatalog?.items.filter(isRellisQualificationRun) ?? [];
|
||||
const openSource = catalog?.sources.find(
|
||||
(source) => source.sourceId === openSourceId,
|
||||
) ?? null;
|
||||
@@ -322,7 +341,28 @@ export function DatasetGatewayWorkspace() {
|
||||
<p>{runError ?? "Нет совместимой версии покадрового анализа."}</p>
|
||||
</section>
|
||||
) : openSource?.sourceKind === "rellis" ? (
|
||||
<RellisDatasetReview />
|
||||
<>
|
||||
{rellisRuns.length > 1 && selectedRellisRunId ? (
|
||||
<div className="dataset-result-select">
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТЫ ОБРАБОТКИ</span>
|
||||
<strong>Версия анализа</strong>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Выбрать версию анализа RELLIS"
|
||||
value={selectedRellisRunId}
|
||||
onChange={(event) => setSelectedRellisRunId(event.target.value)}
|
||||
>
|
||||
{rellisRuns.map((item) => (
|
||||
<option key={item.runId} value={item.runId}>
|
||||
{item.profileGeneration}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<RellisDatasetReview runId={selectedRellisRunId} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<details className="dataset-contract">
|
||||
|
||||
@@ -68,6 +68,9 @@ function secondsBetween(
|
||||
left: GroundReviewFrameSummary,
|
||||
right: GroundReviewFrameSummary,
|
||||
): number {
|
||||
if (left.sensorTimestampNs === null || right.sensorTimestampNs === null) {
|
||||
throw new Error("GOOSE frame timestamp отсутствует.");
|
||||
}
|
||||
const delta = BigInt(right.sensorTimestampNs) - BigInt(left.sensorTimestampNs);
|
||||
return Number(delta) / 1_000_000_000;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
@@ -9,6 +9,13 @@ import {
|
||||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
import {
|
||||
fetchGroundReview,
|
||||
fetchGroundReviewFrame,
|
||||
type GroundReview,
|
||||
type GroundReviewFrame,
|
||||
type GroundReviewFrameSummary,
|
||||
} from "../core/polygon/groundQualification";
|
||||
|
||||
const modes: ReadonlyArray<[LidarGroundViewMode, string]> = [
|
||||
["intensity", "Исходный скан"],
|
||||
@@ -39,7 +46,25 @@ function modeExplanation(mode: LidarGroundViewMode): string {
|
||||
return "Один исходный оборот Ouster OS1 64; цвет — нормализованная remission.";
|
||||
}
|
||||
|
||||
export function RellisDatasetReview() {
|
||||
interface RellisDatasetReviewProps {
|
||||
runId?: string | null;
|
||||
}
|
||||
|
||||
const qualifiedModes: ReadonlyArray<[LidarGroundViewMode, string]> = [
|
||||
["intensity", "Исходный скан"],
|
||||
["ground-truth", "Ground truth"],
|
||||
["current", "Текущий алгоритм"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Ошибки текущего"],
|
||||
["candidate-disagreement", "Ошибки Patchwork++"],
|
||||
];
|
||||
|
||||
export function RellisDatasetReview({ runId = null }: RellisDatasetReviewProps) {
|
||||
if (runId) return <RellisQualifiedReview runId={runId} />;
|
||||
return <RellisSmokeReview />;
|
||||
}
|
||||
|
||||
function RellisSmokeReview() {
|
||||
const [preview, setPreview] = useState<DatasetNativeScanPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<LidarGroundViewMode>("semantic");
|
||||
@@ -237,3 +262,313 @@ export function RellisDatasetReview() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function reviewScene(frame: GroundReviewFrame | null) {
|
||||
if (!frame) return null;
|
||||
return {
|
||||
pointCount: frame.pointCount,
|
||||
pointsXyzM: frame.pointsXyzM,
|
||||
intensity0To255: frame.intensity0To255,
|
||||
masks: {
|
||||
currentGround: frame.currentGround,
|
||||
currentAssigned: frame.evaluated,
|
||||
candidateGround: frame.patchworkGround,
|
||||
candidateAssigned: frame.evaluated,
|
||||
disagreement: frame.currentDisagreement,
|
||||
candidateDisagreement: frame.patchworkDisagreement,
|
||||
groundTruthGround: frame.groundTruthGround,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function groupRellisFrames(review: GroundReview) {
|
||||
const groups = new Map<string, GroundReviewFrameSummary[]>();
|
||||
review.frames.forEach((frame) => {
|
||||
const frames = groups.get(frame.datasetSequenceId) ?? [];
|
||||
frames.push(frame);
|
||||
groups.set(frame.datasetSequenceId, frames);
|
||||
});
|
||||
return [...groups.entries()].map(([sequenceId, frames]) => ({
|
||||
sequenceId,
|
||||
frames,
|
||||
}));
|
||||
}
|
||||
|
||||
function averageMetric(
|
||||
frames: GroundReviewFrameSummary[],
|
||||
value: (frame: GroundReviewFrameSummary) => number,
|
||||
) {
|
||||
return frames.reduce((total, frame) => total + value(frame), 0) / frames.length;
|
||||
}
|
||||
|
||||
function qualifiedModeExplanation(mode: LidarGroundViewMode) {
|
||||
if (mode === "intensity") return "Один исходный Ouster OS1 scan; цвет — remission.";
|
||||
if (mode === "ground-truth") return "Point-aligned ground target из RELLIS labels.";
|
||||
if (mode === "current") return "Результат текущего Mission Core baseline.";
|
||||
if (mode === "candidate") return "Результат закреплённого Patchwork++ v1.4.1.";
|
||||
if (mode === "disagreement") return "Красным — ошибки текущего baseline.";
|
||||
return "Красным — ошибки Patchwork++ относительно labels.";
|
||||
}
|
||||
|
||||
function RellisQualifiedReview({ runId }: { runId: string }) {
|
||||
const [review, setReview] = useState<GroundReview | null>(null);
|
||||
const [frame, setFrame] = useState<GroundReviewFrame | null>(null);
|
||||
const [selectedSequence, setSelectedSequence] = useState<string | null>(null);
|
||||
const [selectedFrame, setSelectedFrame] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<LidarGroundViewMode>("intensity");
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cache = useRef(new Map<string, GroundReviewFrame>());
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setError(null);
|
||||
setReview(null);
|
||||
setFrame(null);
|
||||
cache.current.clear();
|
||||
void fetchGroundReview(runId, { signal: controller.signal })
|
||||
.then((value) => {
|
||||
if (controller.signal.aborted || !value) return;
|
||||
setReview(value);
|
||||
setSelectedSequence(value.frames[0]?.datasetSequenceId ?? null);
|
||||
setSelectedFrame(value.frames[0]?.frameId ?? null);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(loadError));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [runId]);
|
||||
|
||||
const sequences = useMemo(
|
||||
() => review ? groupRellisFrames(review) : [],
|
||||
[review],
|
||||
);
|
||||
const sequence = sequences.find((item) => item.sequenceId === selectedSequence)
|
||||
?? sequences[0]
|
||||
?? null;
|
||||
const position = Math.max(
|
||||
0,
|
||||
sequence?.frames.findIndex((item) => item.frameId === selectedFrame) ?? 0,
|
||||
);
|
||||
const summary = sequence?.frames[position] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFrame) return;
|
||||
const cached = cache.current.get(selectedFrame);
|
||||
if (cached) {
|
||||
setFrame(cached);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void fetchGroundReviewFrame(runId, selectedFrame, { signal: controller.signal })
|
||||
.then((value) => {
|
||||
if (controller.signal.aborted) return;
|
||||
cache.current.set(value.frameId, value);
|
||||
while (cache.current.size > 32) {
|
||||
const first = cache.current.keys().next().value as string | undefined;
|
||||
if (first) cache.current.delete(first);
|
||||
else break;
|
||||
}
|
||||
setFrame(value);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(loadError));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [runId, selectedFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || !sequence || frame?.frameId !== selectedFrame) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const next = sequence.frames[position + 1];
|
||||
if (!next) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
setSelectedFrame(next.frameId);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [frame?.frameId, playing, position, selectedFrame, sequence]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="polygon-review-unavailable">
|
||||
<StatusBadge tone="danger">Review недоступен</StatusBadge>
|
||||
<h3>RELLIS-3D не открыт</h3>
|
||||
<p>{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!review || !sequence) {
|
||||
return (
|
||||
<section className="polygon-review-unavailable">
|
||||
<StatusBadge tone="accent">Читаем индекс</StatusBadge>
|
||||
<h3>Открываем RELLIS validation</h3>
|
||||
<p>Исходные архивы остаются на worker D; в браузер идёт bounded preview.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const selectPosition = (value: number) => {
|
||||
const bounded = Math.max(0, Math.min(value, sequence.frames.length - 1));
|
||||
setPlaying(false);
|
||||
setSelectedFrame(sequence.frames[bounded]?.frameId ?? null);
|
||||
};
|
||||
const patchworkBetter = sequence.frames.filter(
|
||||
(item) => item.groundIouDelta > 0,
|
||||
).length;
|
||||
const scene = reviewScene(frame);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="dataset-sequence-review rellis-review"
|
||||
aria-label="Просмотр RELLIS validation"
|
||||
>
|
||||
<header className="dataset-sequence-review__truth">
|
||||
<div>
|
||||
<span className="section-eyebrow">RELLIS · FULL VALIDATION</span>
|
||||
<h2>Пять последовательностей Ouster OS1 с покадровым сравнением</h2>
|
||||
<p>
|
||||
Все {review.frameCount.toLocaleString("ru-RU")} validation-сканов
|
||||
выровнены с labels и pose index. Высота Patchwork++ определена только
|
||||
на train split; validation labels использованы исключительно для оценки.
|
||||
</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Последовательностей</dt><dd>{sequences.length}</dd></div>
|
||||
<div><dt>Validation scans</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="rellis-sequence">Последовательность датасета</label>
|
||||
<select
|
||||
id="rellis-sequence"
|
||||
value={sequence.sequenceId}
|
||||
onChange={(event) => {
|
||||
const next = sequences.find((item) => item.sequenceId === event.target.value);
|
||||
setPlaying(false);
|
||||
setSelectedSequence(event.target.value);
|
||||
setSelectedFrame(next?.frames[0]?.frameId ?? null);
|
||||
setFrame(null);
|
||||
}}
|
||||
>
|
||||
{sequences.map((item) => (
|
||||
<option key={item.sequenceId} value={item.sequenceId}>
|
||||
Sequence {item.sequenceId} · {item.frames.length} scans
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>
|
||||
Индексы {sequence.frames[0]?.datasetFrameNumber ?? 0}–{
|
||||
sequence.frames[sequence.frames.length - 1]?.datasetFrameNumber ?? 0
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="polygon-review-player">
|
||||
<header className="polygon-review-player__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
SCAN {position + 1} / {sequence.frames.length}
|
||||
{" · SOURCE FRAME "}{summary?.datasetFrameNumber ?? "—"}
|
||||
</span>
|
||||
<strong>{summary?.frameId ?? "Загружаем scan"}</strong>
|
||||
</div>
|
||||
<div className="polygon-review-mode" aria-label="Режим RELLIS review">
|
||||
{qualifiedModes.map(([viewMode, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={viewMode}
|
||||
data-active={mode === viewMode ? "true" : undefined}
|
||||
onClick={() => setMode(viewMode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<div className="polygon-review-controls">
|
||||
<button type="button" onClick={() => setPlaying((value) => !value)}>
|
||||
{playing ? "Пауза" : "Play sequence"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={position === 0}
|
||||
onClick={() => selectPosition(position - 1)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={position === sequence.frames.length - 1}
|
||||
onClick={() => selectPosition(position + 1)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={sequence.frames.length - 1}
|
||||
value={position}
|
||||
aria-label="Позиция в RELLIS sequence"
|
||||
onChange={(event) => selectPosition(Number(event.target.value))}
|
||||
/>
|
||||
<span>{position + 1} / {sequence.frames.length}</span>
|
||||
</div>
|
||||
<div className="polygon-review-stage">
|
||||
{scene ? (
|
||||
<LidarGroundPointCloud
|
||||
key={sequence.sequenceId}
|
||||
frame={scene}
|
||||
mode={mode}
|
||||
coordinateFrame="sensor-fixed"
|
||||
/>
|
||||
) : (
|
||||
<div className="polygon-review-stage__loading">Загружаем scan…</div>
|
||||
)}
|
||||
<div className="polygon-review-stage__legend">
|
||||
<strong>
|
||||
{qualifiedModes.find(([viewMode]) => viewMode === mode)?.[1]}
|
||||
</strong>
|
||||
<span>{qualifiedModeExplanation(mode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="rellis-review__metrics">
|
||||
<article>
|
||||
<span>Ground IoU · Current</span>
|
||||
<strong>{formatPercent(averageMetric(sequence.frames, (item) => item.current.groundIou))}</strong>
|
||||
<small>среднее по выбранной sequence</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Ground IoU · Patchwork++</span>
|
||||
<strong>
|
||||
{formatPercent(averageMetric(sequence.frames, (item) => item.patchwork.groundIou))}
|
||||
</strong>
|
||||
<small>лучше Current на {patchworkBetter} scans</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Obstacle recall · Patchwork++</span>
|
||||
<strong>
|
||||
{formatPercent(
|
||||
averageMetric(
|
||||
sequence.frames,
|
||||
(item) => item.patchwork.obstacleNonGroundRecall,
|
||||
),
|
||||
)}
|
||||
</strong>
|
||||
<small>non-ground сохранён как препятствие</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p className="dataset-sequence-review__raw-note">
|
||||
Это независимая исследовательская проверка переносимости. Она не
|
||||
включает управление машиной и не принимает алгоритм для navigation/safety.
|
||||
Лицензия исходника — CC-BY-NC-SA-3.0.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user