feat: add RELLIS dataset compatibility smoke

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 20:45:30 +03:00
parent 13c35ff446
commit 054feec0d2
19 changed files with 1633 additions and 277 deletions
@@ -8,12 +8,15 @@ import {
import {
fetchDatasetGatewayCatalog,
type DatasetGatewayCatalog,
type DatasetSource,
type DatasetSourceAdmissionStatus,
} from "../core/lidar/datasetGateway";
import {
fetchPolygonRunCatalog,
type PolygonRunCatalog,
} from "../core/polygon/runArchive";
import { GooseDatasetReview } from "./GooseDatasetReview";
import { RellisDatasetReview } from "./RellisDatasetReview";
const representationLabels: Record<string, string> = {
"native-scan": "Исходный скан",
@@ -27,15 +30,17 @@ function errorMessage(error: unknown): string {
: "Каталог датасетов недоступен.";
}
function admissionLabel(status: DatasetGatewayCatalog["source"]["admissionStatus"]) {
const labels: Record<typeof status, string> = {
function admissionLabel(status: DatasetSourceAdmissionStatus): string {
const labels: Record<DatasetSourceAdmissionStatus, string> = {
"blocked-storage-policy": "Worker не настроен",
"ready-for-download": "Готов к загрузке",
"ready-for-smoke": "Готов к smoke",
downloading: "Загружается",
downloaded: "Загружен",
verifying: "Проверяется",
verified: "Проверен",
"frame-ready": "Первый кадр готов",
"frame-ready": "Dataset готов",
"smoke-ready": "Smoke готов",
};
return labels[status];
}
@@ -55,12 +60,42 @@ function isGooseQualificationRun(
&& item.scenarioGeneration.startsWith("goose-3d");
}
function sourceReady(source: DatasetSource): boolean {
return source.admissionStatus === "frame-ready"
|| source.admissionStatus === "smoke-ready";
}
function sourceDescription(source: DatasetSource): string {
if (source.sourceKind === "goose") {
return "Восемь фрагментов и 961 разреженный размеченный оборот VLS-128. Здесь уже опубликовано покадровое сравнение текущего ground baseline и Patchwork++.";
}
return "Независимый off-road источник с Ouster OS1 64. Сейчас открыт официальный frame 000104 для проверки reader, осей, ontology и ground-policy; полный benchmark ещё не выполнялся.";
}
function sourceFooter(source: DatasetSource, storageReady: boolean): string {
if (source.admissionStatus === "frame-ready") {
return "Native scans и разметка приняты. Можно открыть фрагменты и результаты алгоритмов.";
}
if (source.admissionStatus === "smoke-ready") {
return "Официальный пример совместим. Можно проверить scan, классы и ground mapping.";
}
if (source.admissionStatus === "downloading") {
return "Архив остаётся на D worker; после загрузки проверяются digest, лицензия и point-label alignment.";
}
if (!storageReady) {
return "Сначала нужен допущенный Dataset Root на диске D worker.";
}
return source.sourceKind === "rellis"
? "Следующий шаг: пропустить официальный 24 МБ example через smoke admission."
: "Следующий шаг: загрузить validation archive на worker и проверить hash/license.";
}
export function DatasetGatewayWorkspace() {
const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadGeneration, setReloadGeneration] = useState(0);
const [previewOpen, setPreviewOpen] = useState(false);
const [openSourceId, setOpenSourceId] = useState<string | null>(null);
const [runCatalog, setRunCatalog] = useState<PolygonRunCatalog | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runError, setRunError] = useState<string | null>(null);
@@ -85,7 +120,9 @@ export function DatasetGatewayWorkspace() {
}, [reloadGeneration]);
useEffect(() => {
if (catalog?.source.admissionStatus !== "downloading") return;
if (!catalog?.sources.some((source) => source.admissionStatus === "downloading")) {
return;
}
const timer = window.setTimeout(
() => setReloadGeneration((value) => value + 1),
5_000,
@@ -113,10 +150,10 @@ export function DatasetGatewayWorkspace() {
return () => controller.abort();
}, []);
const downloadProgress = catalog?.source.archive
? catalog.source.archive.bytesTransferred / catalog.source.archive.totalBytes
: null;
const gooseRuns = runCatalog?.items.filter(isGooseQualificationRun) ?? [];
const openSource = catalog?.sources.find(
(source) => source.sourceId === openSourceId,
) ?? null;
return (
<div className="standard-workspace dataset-workspace">
@@ -125,17 +162,17 @@ export function DatasetGatewayWorkspace() {
<span className="section-eyebrow">ЗАЧЕМ ЭТО НУЖНО</span>
<h2>Независимая проверка алгоритмов</h2>
<p>
Датасет содержит известные размеченные данные. Мы показываем
конкретный фрагмент, результаты каждого алгоритма и ошибки
относительно разметки. Он не подмешивается в диагностику реального
сенсора и не заменяет closed-loop симуляцию.
Каждый источник остаётся отдельным: собственные sensor domain,
разметка, лицензия и результаты. Совпадение результата на GOOSE и
RELLIS снижает риск подгонки под один датасет, но не заменяет replay
реального сенсора и closed-loop симуляцию.
</p>
</div>
<ol aria-label="Как использовать датасет">
<li><span>01</span>Выбрать фрагмент</li>
<li><span>02</span>Посмотреть сканы</li>
<li><span>01</span>Выбрать источник</li>
<li><span>02</span>Проверить scan и labels</li>
<li><span>03</span>Сравнить алгоритмы</li>
<li><span>04</span>Проверить риски</li>
<li><span>04</span>Проверить переносимость</li>
</ol>
</section>
@@ -171,80 +208,91 @@ export function DatasetGatewayWorkspace() {
</StatusBadge>
</header>
<article className="dataset-entry">
<div className="dataset-entry__identity">
<span className="dataset-entry__mark">G</span>
<div>
<span>PUBLIC · OFF-ROAD · LABELED</span>
<h3>{catalog.source.displayName}</h3>
<p>
Validation split: восемь фрагментов исходных записей и 961
разреженный размеченный оборот VLS-128. Это коллекция native
scans для perception, а не непрерывное видео или траектория
машины.
</p>
</div>
</div>
<dl>
<div>
<dt>Состояние</dt>
<dd>{admissionLabel(catalog.source.admissionStatus)}</dd>
</div>
<div>
<dt>Validation</dt>
<dd>{catalog.source.validationArchiveGb} ГБ</dd>
</div>
<div>
<dt>Группы</dt>
<dd>{catalog.source.superclasses.length} superclass</dd>
</div>
<div>
<dt>Лицензия</dt>
<dd>{catalog.source.license}</dd>
</div>
</dl>
{catalog.source.admissionStatus === "downloading"
&& catalog.source.archive
&& downloadProgress !== null ? (
<div className="dataset-download" aria-label="Загрузка GOOSE validation">
<span style={{ width: `${downloadProgress * 100}%` }} />
<p>
{formatBytes(catalog.source.archive.bytesTransferred)}
{" из "}
{formatBytes(catalog.source.archive.totalBytes)}
</p>
</div>
) : null}
<footer>
<p>
{catalog.source.admissionStatus === "frame-ready"
? "Native scan и разметка прошли admission. Можно открыть независимый ground truth."
: catalog.source.admissionStatus === "downloading"
? "Архив остаётся на D worker. После загрузки проверим ZIP, лицензию, digest и point-label alignment."
: catalog.storage.admitted
? "Следующий шаг: загрузить validation archive на worker и проверить hash/license."
: "Сначала нужно допустить Dataset Root на диске D worker. Сейчас открывать нечего."}
</p>
<div>
<Button
size="compact"
variant="secondary"
disabled={catalog.source.admissionStatus !== "frame-ready"}
title={
catalog.source.admissionStatus === "frame-ready"
? "Открыть фрагменты, сканы и результаты анализа"
: "Размеченные сканы ещё не импортированы"
}
onClick={() => setPreviewOpen((value) => !value)}
<div className="dataset-library__entries">
{catalog.sources.map((source) => {
const downloadProgress = source.archive
? source.archive.bytesTransferred / source.archive.totalBytes
: null;
const opened = source.sourceId === openSourceId;
return (
<article
className="dataset-entry"
data-source={source.sourceKind}
key={source.sourceId}
>
{previewOpen ? "Закрыть датасет" : "Открыть датасет"}
</Button>
</div>
</footer>
</article>
<div className="dataset-entry__identity">
<span className="dataset-entry__mark">
{source.sourceKind === "goose" ? "G" : "R"}
</span>
<div>
<span>
PUBLIC · OFF-ROAD · LABELED
{source.sourceKind === "rellis" ? " · RESEARCH" : ""}
</span>
<h3>{source.displayName}</h3>
<p>{sourceDescription(source)}</p>
</div>
</div>
<dl>
<div>
<dt>Состояние</dt>
<dd>{admissionLabel(source.admissionStatus)}</dd>
</div>
<div>
<dt>Сканер</dt>
<dd>{source.sensor}</dd>
</div>
<div>
<dt>Размеченных сканов</dt>
<dd>{source.annotatedScanCount.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Лицензия</dt>
<dd>{source.license}</dd>
</div>
</dl>
{source.admissionStatus === "downloading"
&& source.archive
&& downloadProgress !== null ? (
<div
className="dataset-download"
aria-label={`Загрузка ${source.displayName}`}
>
<span style={{ width: `${downloadProgress * 100}%` }} />
<p>
{formatBytes(source.archive.bytesTransferred)}
{" из "}
{formatBytes(source.archive.totalBytes)}
</p>
</div>
) : null}
<footer>
<p>{sourceFooter(source, catalog.storage.admitted)}</p>
<div>
<Button
size="compact"
variant="secondary"
disabled={!sourceReady(source)}
title={
sourceReady(source)
? "Открыть содержимое источника"
: "Совместимый preview ещё не опубликован"
}
onClick={() => setOpenSourceId(
opened ? null : source.sourceId,
)}
>
{opened ? "Закрыть датасет" : "Открыть датасет"}
</Button>
</div>
</footer>
</article>
);
})}
</div>
</GlassSurface>
{previewOpen && selectedRunId ? (
{openSource?.sourceKind === "goose" && selectedRunId ? (
<>
{gooseRuns.length > 1 ? (
<div className="dataset-result-select">
@@ -267,12 +315,14 @@ export function DatasetGatewayWorkspace() {
) : null}
<GooseDatasetReview runId={selectedRunId} />
</>
) : previewOpen ? (
) : openSource?.sourceKind === "goose" ? (
<section className="polygon-review-unavailable">
<StatusBadge tone="warning">Review недоступен</StatusBadge>
<h3>Датасет принят, но результаты анализа не опубликованы</h3>
<h3>GOOSE принят, но анализ не опубликован</h3>
<p>{runError ?? "Нет совместимой версии покадрового анализа."}</p>
</section>
) : openSource?.sourceKind === "rellis" ? (
<RellisDatasetReview />
) : null}
<details className="dataset-contract">
@@ -24,6 +24,7 @@ export interface LidarGroundPointCloudFrame {
disagreement: number[];
candidateDisagreement?: number[];
groundTruthGround?: number[];
evaluationMask?: number[];
};
}
@@ -87,13 +88,18 @@ function frameColors(
);
} else if (mode === "ground-truth") {
const groundTruth = frame.masks.groundTruthGround?.[index] === 1;
setRgb(
colors,
offset,
groundTruth ? 0.76 : 0.29,
groundTruth ? 0.86 : 0.33,
groundTruth ? 0.52 : 0.36,
);
const evaluated = frame.masks.evaluationMask?.[index] !== 0;
if (!evaluated) {
setRgb(colors, offset, 0.86, 0.61, 0.24);
} else {
setRgb(
colors,
offset,
groundTruth ? 0.76 : 0.29,
groundTruth ? 0.86 : 0.33,
groundTruth ? 0.52 : 0.36,
);
}
} else if (mode === "current") {
setRgb(
colors,
@@ -0,0 +1,239 @@
import { useEffect, useMemo, useState } from "react";
import { StatusBadge } from "@nodedc/ui-react";
import {
fetchDatasetNativeScanPreview,
type DatasetNativeScanPreview,
} from "../core/lidar/datasetGateway";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
const modes: ReadonlyArray<[LidarGroundViewMode, string]> = [
["intensity", "Исходный скан"],
["semantic", "Классы RELLIS"],
["ground-truth", "Ground target"],
];
function formatPercent(value: number): string {
return new Intl.NumberFormat("ru-RU", {
style: "percent",
maximumFractionDigits: 1,
}).format(value);
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Официальный пример RELLIS недоступен.";
}
function modeExplanation(mode: LidarGroundViewMode): string {
if (mode === "semantic") {
return "Цвета из закреплённой официальной ontology RELLIS-3D.";
}
if (mode === "ground-truth") {
return "Зелёный — ground, серый — non-ground, янтарный — ignore.";
}
return "Один исходный оборот Ouster OS1 64; цвет — нормализованная remission.";
}
export function RellisDatasetReview() {
const [preview, setPreview] = useState<DatasetNativeScanPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<LidarGroundViewMode>("semantic");
useEffect(() => {
const controller = new AbortController();
setError(null);
void fetchDatasetNativeScanPreview({
sourceId: "rellis-3d/v1.1",
signal: controller.signal,
})
.then((value) => {
if (!controller.signal.aborted) setPreview(value);
})
.catch((loadError: unknown) => {
if (!controller.signal.aborted) setError(errorMessage(loadError));
});
return () => controller.abort();
}, []);
const scene = useMemo(() => {
if (!preview) return null;
const empty = Array.from({ length: preview.pointCount }, () => 0);
return {
pointCount: preview.pointCount,
pointsXyzM: preview.pointsXyzM,
intensity0To255: preview.remission0To255,
semanticRgb0To255: preview.semanticRgb0To255,
masks: {
currentGround: empty,
currentAssigned: preview.evaluationMask,
candidateGround: empty,
candidateAssigned: preview.evaluationMask,
disagreement: empty,
groundTruthGround: preview.groundTruthGround,
evaluationMask: preview.evaluationMask,
},
};
}, [preview]);
if (error) {
return (
<section className="polygon-review-unavailable">
<StatusBadge tone="danger">Smoke preview недоступен</StatusBadge>
<h3>RELLIS-3D не открыт</h3>
<p>{error}</p>
</section>
);
}
if (!preview || !scene) {
return (
<section className="polygon-review-unavailable">
<StatusBadge tone="accent">Проверяем пример</StatusBadge>
<h3>Открываем официальный RELLIS scan</h3>
<p>Исходные dataset bytes не переносятся в браузер.</p>
</section>
);
}
const evaluatedPoints = preview.evaluationMask.reduce(
(total, value) => total + value,
0,
);
const groundPoints = preview.groundTruthGround.reduce(
(total, value, index) => total + value * (preview.evaluationMask[index] ?? 0),
0,
);
const groundClasses = preview.classes.filter(
(item) => item.groundTarget === "ground",
);
const nonGroundClasses = preview.classes.filter(
(item) => item.groundTarget === "non-ground",
);
const ignoredClasses = preview.classes.filter(
(item) => item.groundTarget === "ignore",
);
return (
<section
className="dataset-sequence-review rellis-review"
aria-label="Просмотр официального примера RELLIS-3D"
>
<header className="dataset-sequence-review__truth">
<div>
<span className="section-eyebrow">RELLIS S0 · COMPATIBILITY SMOKE</span>
<h2>Официальный Ouster scan читается без конвертации формата</h2>
<p>
Frame {preview.frameId}: исходные XYZI и point-wise labels остались
выровнены. Это проверка reader, осей, цветов и ground-policy ещё не
сравнение алгоритмов и не основание для навигации.
</p>
</div>
<dl>
<div>
<dt>Исходных точек</dt>
<dd>{preview.sourcePointCount.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Browser preview</dt>
<dd>{preview.pointCount.toLocaleString("ru-RU")}</dd>
</div>
<div>
<dt>Sensor frame</dt>
<dd>{preview.coordinateFrame?.frameId ?? "—"}</dd>
</div>
</dl>
</header>
<section className="polygon-review-player">
<header className="polygon-review-player__header">
<div>
<span className="section-eyebrow">OFFICIAL EXAMPLE · FRAME 000104</span>
<strong>Ouster OS1 64 · X forward · Y left · Z up</strong>
</div>
<div className="polygon-review-mode" aria-label="Режим RELLIS preview">
{modes.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-stage">
<LidarGroundPointCloud
frame={scene}
mode={mode}
coordinateFrame="sensor-fixed"
/>
<div className="polygon-review-stage__legend">
<strong>{modes.find(([viewMode]) => viewMode === mode)?.[1]}</strong>
<span>{modeExplanation(mode)}</span>
</div>
</div>
</section>
<div className="rellis-review__metrics">
<article>
<span>Оценимых точек preview</span>
<strong>{formatPercent(evaluatedPoints / preview.pointCount)}</strong>
<small>ignore не участвует в метриках</small>
</article>
<article>
<span>Ground среди оценимых</span>
<strong>
{formatPercent(evaluatedPoints ? groundPoints / evaluatedPoints : 0)}
</strong>
<small>версионная mapping policy v1</small>
</article>
<article>
<span>Классов в этом scan</span>
<strong>{preview.classes.length}</strong>
<small>из 20 классов ontology</small>
</article>
</div>
<section className="rellis-review__policy" aria-label="Ground target mapping">
<header>
<span className="section-eyebrow">GROUND TARGET POLICY V1</span>
<h3>Что именно будет оцениваться</h3>
<p>
Mapping принадлежит Mission Core и версионируется отдельно от
ontology датасета. Вода, лужи, void, sky и неопределённый object не
засчитываются ни как ground, ни как obstacle.
</p>
</header>
<div>
<article>
<span>Ground</span>
<strong>{groundClasses.map((item) => item.className).join(" · ") || "—"}</strong>
</article>
<article>
<span>Non-ground</span>
<strong>
{nonGroundClasses.map((item) => item.className).join(" · ") || "—"}
</strong>
</article>
<article>
<span>Ignore</span>
<strong>{ignoredClasses.map((item) => item.className).join(" · ") || "—"}</strong>
</article>
</div>
</section>
<p className="dataset-sequence-review__raw-note">
Лицензия RELLIS-3D CC-BY-NC-SA-3.0: источник годится для независимой
исследовательской проверки, но коммерческое использование данных или
производных моделей требует отдельного лицензионного решения.
</p>
</section>
);
}