feat(lidar): admit and benchmark GOOSE baseline
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user