feat(lidar): admit and benchmark GOOSE baseline

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 14:14:20 +03:00
parent 881e97312b
commit 951b40c870
20 changed files with 2871 additions and 241 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
GlassSurface,
@@ -7,8 +7,16 @@ import {
import {
fetchDatasetGatewayCatalog,
fetchDatasetGroundComparison,
fetchDatasetNativeScanPreview,
type DatasetGatewayCatalog,
type DatasetGroundComparison,
type DatasetNativeScanPreview,
} from "../core/lidar/datasetGateway";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
const representationLabels: Record<string, string> = {
"native-scan": "Исходный скан",
@@ -22,11 +30,39 @@ function errorMessage(error: unknown): string {
: "Каталог датасетов недоступен.";
}
function admissionLabel(status: DatasetGatewayCatalog["source"]["admissionStatus"]) {
const labels: Record<typeof status, string> = {
"blocked-storage-policy": "Worker не настроен",
"ready-for-download": "Готов к загрузке",
downloading: "Загружается",
downloaded: "Загружен",
verifying: "Проверяется",
verified: "Проверен",
"frame-ready": "Первый кадр готов",
};
return labels[status];
}
function formatBytes(value: number): string {
return new Intl.NumberFormat("ru-RU", {
style: "unit",
unit: "gigabyte",
maximumFractionDigits: 2,
}).format(value / 1_000_000_000);
}
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 [preview, setPreview] = useState<DatasetNativeScanPreview | null>(null);
const [comparison, setComparison] = useState<DatasetGroundComparison | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [comparisonError, setComparisonError] = useState<string | null>(null);
const [previewMode, setPreviewMode] =
useState<LidarGroundViewMode>("semantic");
useEffect(() => {
const controller = new AbortController();
@@ -47,6 +83,73 @@ export function DatasetGatewayWorkspace() {
return () => controller.abort();
}, [reloadGeneration]);
useEffect(() => {
if (catalog?.source.admissionStatus !== "downloading") return;
const timer = window.setTimeout(
() => setReloadGeneration((value) => value + 1),
5_000,
);
return () => window.clearTimeout(timer);
}, [catalog]);
useEffect(() => {
if (!previewOpen || preview) return;
const controller = new AbortController();
setPreviewError(null);
void fetchDatasetNativeScanPreview({ signal: controller.signal })
.then((value) => {
if (!controller.signal.aborted) setPreview(value);
})
.catch((loadError: unknown) => {
if (!controller.signal.aborted) setPreviewError(errorMessage(loadError));
});
return () => controller.abort();
}, [previewOpen, preview]);
useEffect(() => {
if (!previewOpen || comparison) return;
const controller = new AbortController();
setComparisonError(null);
void fetchDatasetGroundComparison({ signal: controller.signal })
.then((value) => {
if (!controller.signal.aborted) setComparison(value);
})
.catch((loadError: unknown) => {
if (!controller.signal.aborted) {
setComparisonError(errorMessage(loadError));
}
});
return () => controller.abort();
}, [comparison, previewOpen]);
const previewFrame = useMemo(() => {
if (!preview) return null;
const emptyMask = new Array<number>(preview.pointCount).fill(0);
const comparisonAligned = (
comparison
&& comparison.frameId === preview.frameId
&& comparison.pointCount === preview.pointCount
) ? comparison : null;
return {
pointCount: preview.pointCount,
pointsXyzM: preview.pointsXyzM,
intensity0To255: preview.remission0To255,
semanticRgb0To255: preview.semanticRgb0To255,
masks: {
currentGround: comparisonAligned?.currentGround ?? emptyMask,
currentAssigned: comparisonAligned?.evaluated ?? emptyMask,
candidateGround: comparisonAligned?.groundTruthGround ?? emptyMask,
candidateAssigned: comparisonAligned?.evaluated ?? emptyMask,
disagreement: comparisonAligned?.disagreement ?? emptyMask,
groundTruthGround: preview.groundTruthGround,
},
};
}, [comparison, preview]);
const downloadProgress = catalog?.source.archive
? catalog.source.archive.bytesTransferred / catalog.source.archive.totalBytes
: null;
return (
<div className="standard-workspace dataset-workspace">
<section className="dataset-purpose" aria-label="Назначение датасетов">
@@ -117,35 +220,56 @@ export function DatasetGatewayWorkspace() {
<dl>
<div>
<dt>Состояние</dt>
<dd>Не загружен</dd>
<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} классов</dd>
<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.storage.admitted
? "Следующий шаг: загрузить validation archive на worker и проверить hash/license."
: "Сначала нужно допустить Dataset Root на диске D worker. Сейчас открывать нечего."}
{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
title="Первый реальный кадр ещё не импортирован"
disabled={catalog.source.admissionStatus !== "frame-ready"}
title={
catalog.source.admissionStatus === "frame-ready"
? "Открыть первый размеченный native scan"
: "Первый реальный кадр ещё не импортирован"
}
onClick={() => setPreviewOpen((value) => !value)}
>
Открыть
{previewOpen ? "Закрыть" : "Открыть"}
</Button>
<Button
size="compact"
@@ -159,6 +283,106 @@ export function DatasetGatewayWorkspace() {
</article>
</GlassSurface>
{previewOpen ? (
<section className="dataset-preview" aria-label="Первый GOOSE native scan">
<header>
<div>
<span className="section-eyebrow">NATIVE SCAN · GROUND TRUTH</span>
<h2>
{preview
? `${preview.frameId} · ${preview.sourcePointCount.toLocaleString("ru-RU")} точек`
: "Первый размеченный кадр"}
</h2>
</div>
<div className="lidar-ground-modes" aria-label="Режим окраски">
{([
["semantic", "Классы"],
["ground-truth", "Ground"],
...(comparison
? ([
["current", "Current"],
["disagreement", "Ошибки"],
] as const)
: []),
["intensity", "Remission"],
] as const).map(([mode, label]) => (
<button
key={mode}
type="button"
aria-pressed={previewMode === mode}
onClick={() => setPreviewMode(mode)}
>
{label}
</button>
))}
</div>
</header>
{comparison ? (
<dl className="dataset-preview__metrics">
<div>
<dt>Ground IoU</dt>
<dd>{(comparison.metrics.groundIou * 100).toFixed(1)}%</dd>
</div>
<div>
<dt>Precision</dt>
<dd>{(comparison.metrics.precision * 100).toFixed(1)}%</dd>
</div>
<div>
<dt>Recall</dt>
<dd>{(comparison.metrics.recall * 100).toFixed(1)}%</dd>
</div>
<div>
<dt>Latency</dt>
<dd>{comparison.latencyMs.toFixed(0)} ms</dd>
</div>
</dl>
) : comparisonError ? (
<p className="dataset-preview__comparison-note">
Current baseline пока не рассчитан: {comparisonError}
</p>
) : null}
{previewFrame ? (
<>
<LidarGroundPointCloud frame={previewFrame} mode={previewMode} />
<div className="dataset-preview__legend">
{previewMode === "semantic" ? (
preview?.classes.map((item) => (
<span key={item.labelId}>
<i style={{ background: item.hex }} />
{item.className}
</span>
))
) : previewMode === "ground-truth" ? (
<>
<span><i className="dataset-legend-ground" />ground truth</span>
<span><i className="dataset-legend-other" />остальные точки</span>
</>
) : previewMode === "current" ? (
<>
<span><i className="dataset-legend-ground" />current ground</span>
<span><i className="dataset-legend-other" />current non-ground</span>
</>
) : previewMode === "disagreement" ? (
<>
<span><i className="dataset-legend-error" />ошибка относительно labels</span>
<span><i className="dataset-legend-other" />совпадение</span>
</>
) : (
<span>Remission нормализован только для визуального просмотра 0–255</span>
)}
</div>
</>
) : (
<div className="lidar-ground-scene-placeholder">
<StatusBadge tone={previewError ? "danger" : "accent"}>
{previewError ? "Preview недоступен" : "Читаем frame"}
</StatusBadge>
<p>{previewError ?? "Проверяем point alignment и разметку."}</p>
</div>
)}
</section>
) : null}
<details className="dataset-contract">
<summary>
<span>Технический контракт</span>
@@ -6,18 +6,22 @@ export type LidarGroundViewMode =
| "intensity"
| "current"
| "candidate"
| "disagreement";
| "disagreement"
| "semantic"
| "ground-truth";
export interface LidarGroundPointCloudFrame {
pointCount: number;
pointsXyzM: Array<[number, number, number]>;
intensity0To255: number[] | null;
semanticRgb0To255?: number[] | null;
masks: {
currentGround: number[];
currentAssigned: number[];
candidateGround: number[];
candidateAssigned: number[];
disagreement: number[];
groundTruthGround?: number[];
};
}
@@ -68,6 +72,24 @@ function frameColors(
neutral,
neutral,
);
} else if (mode === "semantic") {
const semantic = frame.semanticRgb0To255;
setRgb(
colors,
offset,
(semantic?.[offset] ?? 96) / 255,
(semantic?.[offset + 1] ?? 96) / 255,
(semantic?.[offset + 2] ?? 96) / 255,
);
} else if (mode === "ground-truth") {
const groundTruth = frame.masks.groundTruthGround?.[index] === 1;
setRgb(
colors,
offset,
groundTruth ? 0.78 : 0.29,
groundTruth ? 0.9 : 0.33,
groundTruth ? 0.42 : 0.38,
);
} else if (mode === "current") {
setRgb(
colors,
@@ -88,14 +110,17 @@ function frameColors(
candidate ? 1 : 0.43,
);
}
} else if (current && candidate) {
setRgb(colors, offset, 0.73, 1, 0.29);
} else if (current) {
setRgb(colors, offset, 1, 0.64, 0.18);
} else if (candidate) {
setRgb(colors, offset, 0.24, 0.84, 1);
} else if (mode === "disagreement") {
const disagreement = frame.masks.disagreement[index] === 1;
setRgb(
colors,
offset,
disagreement ? 1 : 0.24,
disagreement ? 0.31 : 0.29,
disagreement ? 0.22 : 0.35,
);
} else {
setRgb(colors, offset, 0.24, 0.29, 0.35);
setRgb(colors, offset, candidate ? 0.24 : 0.29, candidate ? 0.84 : 0.36, 0.43);
}
}
return colors;