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
@@ -8,6 +8,8 @@ export interface DatasetGatewayCatalog {
configured: boolean;
admitted: boolean;
status: "ready" | "blocked-storage-policy";
attestation: "worker-manifest" | "local-worker-path" | "none";
manifestValid: boolean;
requiredWindowsRoot: string;
requiredWslRoot: string;
};
@@ -21,7 +23,31 @@ export interface DatasetGatewayCatalog {
platforms: string[];
superclasses: string[];
validationArchiveGb: number;
admissionStatus: "ready-for-download" | "blocked-storage-policy";
admissionStatus:
| "blocked-storage-policy"
| "ready-for-download"
| "downloading"
| "downloaded"
| "verifying"
| "verified"
| "frame-ready";
archive: {
bytesTransferred: number;
totalBytes: number;
sizeBytes: number | null;
sha256: string | null;
integrity: string;
vendorChecksumAvailable: false;
} | null;
frame: {
frameId: string;
pointCount: number;
semanticClassCount: number;
groundTruthGroundFraction: number;
previewPointCount: number;
previewSha256: string;
previewAvailable: true;
} | null;
};
representations: Array<{
id: DatasetRepresentationId;
@@ -45,6 +71,46 @@ export interface DatasetGatewayCatalog {
nextAction: string;
}
export interface DatasetNativeScanPreview {
sourceId: "goose-3d/v2025-08-22";
frameId: string;
sourcePointCount: number;
pointCount: number;
pointsXyzM: Array<[number, number, number]>;
remission0To255: number[];
semanticLabelIds: number[];
semanticRgb0To255: number[];
groundTruthGround: number[];
classes: Array<{
labelId: number;
className: string;
hex: string;
challengeCategoryId: number;
challengeCategoryName: string;
}>;
}
export interface DatasetGroundComparison {
sourceId: "goose-3d/v2025-08-22";
frameId: string;
pointCount: number;
currentGround: number[];
groundTruthGround: number[];
evaluated: number[];
disagreement: number[];
metrics: {
precision: number;
recall: number;
f1: number;
groundIou: number;
accuracy: number;
artificialGroundRecall: number;
naturalGroundRecall: number;
obstacleNonGroundRecall: number;
};
latencyMs: number;
}
export class DatasetGatewayContractError extends Error {}
type DatasetFetch = (
@@ -105,12 +171,43 @@ function number(value: unknown, label: string): number {
return value;
}
function nullableNumber(value: unknown, label: string): number | null {
return value === null ? null : number(value, label);
}
function nullableDigest(value: unknown, label: string): string | null {
if (value === null) return null;
const digest = string(value, label);
if (!/^[a-f0-9]{64}$/.test(digest)) {
throw new DatasetGatewayContractError(`${label}: некорректный SHA-256`);
}
return digest;
}
function integers(
value: unknown,
label: string,
maximum: number,
): number[] {
return array(value, label).map((item, index) => {
if (
typeof item !== "number"
|| !Number.isInteger(item)
|| item < 0
|| item > maximum
) {
throw new DatasetGatewayContractError(`${label}[${index}]: некорректное число`);
}
return item;
});
}
export function parseDatasetGatewayCatalog(
value: unknown,
): DatasetGatewayCatalog {
const source = record(value, "Dataset Gateway");
if (
source.schema_version !== "missioncore.dataset-gateway-catalog/v1"
source.schema_version !== "missioncore.dataset-gateway-catalog/v2"
|| source.access !== "read-only"
) {
throw new DatasetGatewayContractError("Dataset Gateway contract несовместим");
@@ -123,6 +220,14 @@ export function parseDatasetGatewayCatalog(
if (storage.path_exposed !== false) {
throw new DatasetGatewayContractError("Dataset Gateway раскрыл локальный путь");
}
const storageAttestation = storage.attestation;
if (
storageAttestation !== "worker-manifest"
&& storageAttestation !== "local-worker-path"
&& storageAttestation !== "none"
) {
throw new DatasetGatewayContractError("storage.attestation: неизвестное значение");
}
const sources = array(source.sources, "sources");
if (sources.length !== 1) {
throw new DatasetGatewayContractError("Ожидался один первичный dataset source");
@@ -137,9 +242,72 @@ export function parseDatasetGatewayCatalog(
if (
admissionStatus !== "ready-for-download"
&& admissionStatus !== "blocked-storage-policy"
&& admissionStatus !== "downloading"
&& admissionStatus !== "downloaded"
&& admissionStatus !== "verifying"
&& admissionStatus !== "verified"
&& admissionStatus !== "frame-ready"
) {
throw new DatasetGatewayContractError("source admission status неизвестен");
}
const archive = admission.archive === null
? null
: record(admission.archive, "source.admission.archive");
const archiveValue = archive
? {
bytesTransferred: number(
archive.bytes_transferred,
"archive.bytes_transferred",
),
totalBytes: number(archive.total_bytes, "archive.total_bytes"),
sizeBytes: nullableNumber(archive.size_bytes, "archive.size_bytes"),
sha256: nullableDigest(archive.sha256, "archive.sha256"),
integrity: string(archive.integrity, "archive.integrity", true),
vendorChecksumAvailable: (() => {
if (archive.vendor_checksum_available !== false) {
throw new DatasetGatewayContractError(
"archive.vendor_checksum_available: ожидался false",
);
}
return false as const;
})(),
}
: null;
const admittedFrame = admission.frame === null
? null
: record(admission.frame, "source.admission.frame");
const frameValue = admittedFrame
? {
frameId: string(admittedFrame.frame_id, "frame.frame_id", true),
pointCount: number(admittedFrame.point_count, "frame.point_count"),
semanticClassCount: number(
admittedFrame.semantic_class_count,
"frame.semantic_class_count",
),
groundTruthGroundFraction: number(
admittedFrame.ground_truth_ground_fraction,
"frame.ground_truth_ground_fraction",
),
previewPointCount: number(
admittedFrame.preview_point_count,
"frame.preview_point_count",
),
previewSha256: nullableDigest(
admittedFrame.preview_sha256,
"frame.preview_sha256",
) ?? (() => {
throw new DatasetGatewayContractError("frame.preview_sha256 отсутствует");
})(),
previewAvailable: (() => {
if (admittedFrame.preview_available !== true) {
throw new DatasetGatewayContractError(
"frame.preview_available: ожидался true",
);
}
return true as const;
})(),
}
: null;
const representations = array(
source.representations,
"representations",
@@ -188,6 +356,8 @@ export function parseDatasetGatewayCatalog(
configured: boolean(storage.configured, "storage.configured"),
admitted: boolean(storage.admitted, "storage.admitted"),
status: storageStatus,
attestation: storageAttestation,
manifestValid: boolean(storage.manifest_valid, "storage.manifest_valid"),
requiredWindowsRoot: string(
storage.required_windows_root,
"storage.required_windows_root",
@@ -208,6 +378,8 @@ export function parseDatasetGatewayCatalog(
"validation_archive_gb",
),
admissionStatus,
archive: archiveValue,
frame: frameValue,
},
representations,
pipeline,
@@ -223,6 +395,183 @@ export function parseDatasetGatewayCatalog(
};
}
export function parseDatasetNativeScanPreview(
value: unknown,
): DatasetNativeScanPreview {
const source = record(value, "Dataset preview");
if (
source.schema_version !== "missioncore.dataset-native-scan-preview/v1"
|| source.source_id !== "goose-3d/v2025-08-22"
|| source.representation !== "native-scan"
|| source.sampling !== "deterministic-even-index"
) {
throw new DatasetGatewayContractError("Dataset preview contract несовместим");
}
const sourcePointCount = number(source.source_point_count, "source_point_count");
const pointCount = number(source.point_count, "point_count");
const pointsXyzM = array(source.points_xyz_m, "points_xyz_m").map(
(value, index): [number, number, number] => {
const point = array(value, `points_xyz_m[${index}]`);
if (
point.length !== 3
|| point.some((coordinate) =>
typeof coordinate !== "number" || !Number.isFinite(coordinate)
)
) {
throw new DatasetGatewayContractError(
`points_xyz_m[${index}]: некорректная точка`,
);
}
return [point[0] as number, point[1] as number, point[2] as number];
},
);
const remission0To255 = integers(
source.remission_0_to_255,
"remission_0_to_255",
255,
);
const semanticLabelIds = integers(
source.semantic_label_ids,
"semantic_label_ids",
65_535,
);
const semanticRgb0To255 = integers(
source.semantic_rgb_0_to_255,
"semantic_rgb_0_to_255",
255,
);
const groundTruthGround = integers(
source.ground_truth_ground,
"ground_truth_ground",
1,
);
if (
pointCount < 1
|| pointCount > 50_000
|| pointCount > sourcePointCount
|| pointsXyzM.length !== pointCount
|| remission0To255.length !== pointCount
|| semanticLabelIds.length !== pointCount
|| semanticRgb0To255.length !== pointCount * 3
|| groundTruthGround.length !== pointCount
) {
throw new DatasetGatewayContractError("Dataset preview arrays не выровнены");
}
const classes = array(source.classes, "classes").map((value, index) => {
const item = record(value, `classes[${index}]`);
return {
labelId: number(item.label_id, "class.label_id"),
className: string(item.class_name, "class.class_name", true),
hex: string(item.hex, "class.hex"),
challengeCategoryId: number(
item.challenge_category_id,
"class.challenge_category_id",
),
challengeCategoryName: string(
item.challenge_category_name,
"class.challenge_category_name",
true,
),
};
});
const safety = record(source.safety, "safety");
if (
safety.visualization_only !== true
|| safety.navigation_or_safety_accepted !== false
) {
throw new DatasetGatewayContractError("Dataset preview safety boundary нарушен");
}
return {
sourceId: "goose-3d/v2025-08-22",
frameId: string(source.frame_id, "frame_id", true),
sourcePointCount,
pointCount,
pointsXyzM,
remission0To255,
semanticLabelIds,
semanticRgb0To255,
groundTruthGround,
classes,
};
}
export function parseDatasetGroundComparison(
value: unknown,
): DatasetGroundComparison {
const source = record(value, "Ground comparison");
if (
source.schema_version !== "missioncore.dataset-ground-comparison-preview/v1"
|| source.source_id !== "goose-3d/v2025-08-22"
|| source.sampling !== "deterministic-even-index"
) {
throw new DatasetGatewayContractError("Ground comparison contract несовместим");
}
const pointCount = number(source.point_count, "point_count");
const currentGround = integers(source.current_ground, "current_ground", 1);
const groundTruthGround = integers(
source.ground_truth_ground,
"ground_truth_ground",
1,
);
const evaluated = integers(source.evaluated, "evaluated", 1);
const disagreement = integers(source.disagreement, "disagreement", 1);
if (
pointCount < 1
|| pointCount > 50_000
|| currentGround.length !== pointCount
|| groundTruthGround.length !== pointCount
|| evaluated.length !== pointCount
|| disagreement.length !== pointCount
) {
throw new DatasetGatewayContractError("Ground comparison arrays не выровнены");
}
const metrics = record(source.metrics, "metrics");
const fraction = (key: string): number => {
const value = number(metrics[key], `metrics.${key}`);
if (value > 1) {
throw new DatasetGatewayContractError(`metrics.${key}: ожидалась доля`);
}
return value;
};
const provider = record(source.provider, "provider");
if (
provider.provider_id !== "missioncore-local-percentile-ground/v1"
|| provider.ground_truth !== false
|| !/^[a-f0-9]{64}$/.test(
string(provider.implementation_sha256, "provider.implementation_sha256"),
)
) {
throw new DatasetGatewayContractError("Ground comparison provider несовместим");
}
const safety = record(source.safety, "safety");
if (
safety.qualification_only !== true
|| safety.navigation_or_safety_accepted !== false
) {
throw new DatasetGatewayContractError("Ground comparison safety boundary нарушен");
}
return {
sourceId: "goose-3d/v2025-08-22",
frameId: string(source.frame_id, "frame_id", true),
pointCount,
currentGround,
groundTruthGround,
evaluated,
disagreement,
metrics: {
precision: fraction("precision"),
recall: fraction("recall"),
f1: fraction("f1"),
groundIou: fraction("ground_iou"),
accuracy: fraction("accuracy"),
artificialGroundRecall: fraction("artificial_ground_recall"),
naturalGroundRecall: fraction("natural_ground_recall"),
obstacleNonGroundRecall: fraction("obstacle_non_ground_recall"),
},
latencyMs: number(source.latency_ms, "latency_ms"),
};
}
async function responseJson(response: Response): Promise<unknown> {
if (!response.ok) {
throw new Error(`Dataset Gateway HTTP ${response.status}`);
@@ -241,3 +590,30 @@ export async function fetchDatasetGatewayCatalog(
});
return parseDatasetGatewayCatalog(await responseJson(response));
}
export async function fetchDatasetNativeScanPreview(
options: { signal?: AbortSignal; fetcher?: DatasetFetch } = {},
): Promise<DatasetNativeScanPreview> {
const fetcher = options.fetcher ?? fetch;
const response = await fetcher("/api/v1/lidar/dataset-gateway/preview", {
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
});
return parseDatasetNativeScanPreview(await responseJson(response));
}
export async function fetchDatasetGroundComparison(
options: { signal?: AbortSignal; fetcher?: DatasetFetch } = {},
): Promise<DatasetGroundComparison> {
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
"/api/v1/lidar/dataset-gateway/ground-comparison",
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseDatasetGroundComparison(await responseJson(response));
}
@@ -122,6 +122,15 @@
grid-template-columns: 1fr;
}
.dataset-preview > header {
align-items: flex-start;
flex-direction: column;
}
.dataset-preview__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.lidar-device-context__verdict {
border-top: 1px solid var(--station-hairline);
border-left: 0;
@@ -1098,6 +1098,128 @@
gap: 0.5rem;
}
.dataset-download {
position: relative;
grid-column: 1 / -1;
overflow: hidden;
height: 1.7rem;
border-radius: 0.65rem;
background: rgb(255 255 255 / 0.045);
}
.dataset-download > span {
position: absolute;
inset: 0 auto 0 0;
background: rgb(255 255 255 / 0.16);
transition: width 300ms ease;
}
.dataset-download p {
position: relative;
z-index: 1;
display: grid;
height: 100%;
place-items: center;
color: var(--nodedc-text-secondary);
font-size: 0.58rem;
}
.dataset-preview {
display: grid;
gap: 0.8rem;
padding: 1rem;
border-radius: 0.9rem;
background: var(--station-panel);
}
.dataset-preview > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.dataset-preview h2 {
margin-top: 0.22rem;
color: var(--nodedc-text-primary);
font-size: 0.92rem;
}
.dataset-preview .lidar-ground-scene {
min-height: 31rem;
border: 0;
}
.dataset-preview__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border-radius: 0.7rem;
background: rgb(255 255 255 / 0.025);
}
.dataset-preview__metrics div {
display: grid;
gap: 0.2rem;
padding: 0.65rem 0.75rem;
}
.dataset-preview__metrics div + div {
border-left: 1px solid var(--station-hairline);
}
.dataset-preview__metrics dt {
color: var(--nodedc-text-muted);
font-size: 0.52rem;
}
.dataset-preview__metrics dd {
margin: 0;
color: var(--nodedc-text-primary);
font-size: 0.72rem;
font-weight: 600;
}
.dataset-preview__comparison-note {
color: var(--nodedc-text-muted);
font-size: 0.6rem;
}
.dataset-preview__legend {
display: flex;
overflow-x: auto;
gap: 0.65rem;
padding: 0.1rem 0.15rem 0.15rem;
scrollbar-width: thin;
}
.dataset-preview__legend span {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 0.32rem;
color: var(--nodedc-text-muted);
font-size: 0.55rem;
}
.dataset-preview__legend i {
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
}
.dataset-preview__legend .dataset-legend-ground {
background: rgb(199 230 107);
}
.dataset-preview__legend .dataset-legend-other {
background: rgb(74 84 97);
}
.dataset-preview__legend .dataset-legend-error {
background: rgb(255 79 56);
}
.dataset-contract {
overflow: hidden;
background: var(--station-panel);
@@ -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 нормализован только для визуального просмотра 0255</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;
@@ -5,7 +5,11 @@ import { createServer } from "vite";
let server;
let parseDatasetGatewayCatalog;
let parseDatasetNativeScanPreview;
let parseDatasetGroundComparison;
let fetchDatasetGatewayCatalog;
let fetchDatasetNativeScanPreview;
let fetchDatasetGroundComparison;
let DatasetGatewayContractError;
before(async () => {
@@ -16,7 +20,11 @@ before(async () => {
});
({
parseDatasetGatewayCatalog,
parseDatasetNativeScanPreview,
parseDatasetGroundComparison,
fetchDatasetGatewayCatalog,
fetchDatasetNativeScanPreview,
fetchDatasetGroundComparison,
DatasetGatewayContractError,
} = await server.ssrLoadModule("/src/core/lidar/datasetGateway.ts"));
});
@@ -27,7 +35,7 @@ after(async () => {
function catalog(overrides = {}) {
return {
schema_version: "missioncore.dataset-gateway-catalog/v1",
schema_version: "missioncore.dataset-gateway-catalog/v2",
access: "read-only",
storage: {
configured: false,
@@ -35,6 +43,8 @@ function catalog(overrides = {}) {
required_wsl_root: "/mnt/d/NDC_MISSIONCORE/datasets",
admitted: false,
status: "blocked-storage-policy",
attestation: "none",
manifest_valid: false,
path_exposed: false,
},
sources: [{
@@ -54,6 +64,8 @@ function catalog(overrides = {}) {
},
admission: {
status: "blocked-storage-policy",
archive: null,
frame: null,
},
}],
representations: [
@@ -137,3 +149,99 @@ test("fetches the read-only gateway endpoint", async () => {
assert.equal(calls[0].init.method, "GET");
assert.equal(parsed.source.displayName, "GOOSE 3D");
});
test("decodes one bounded point-aligned native-scan preview", async () => {
const payload = {
schema_version: "missioncore.dataset-native-scan-preview/v1",
source_id: "goose-3d/v2025-08-22",
frame_id: "frame-1",
representation: "native-scan",
sampling: "deterministic-even-index",
source_point_count: 2,
point_count: 2,
points_xyz_m: [[1, 2, 3], [4, 5, 6]],
remission_0_to_255: [0, 255],
semantic_label_ids: [23, 38],
semantic_rgb_0_to_255: [255, 47, 128, 1, 51, 73],
ground_truth_ground: [1, 0],
classes: [
{
label_id: 23,
class_name: "asphalt",
hex: "#ff2f80",
challenge_category_id: 2,
challenge_category_name: "artificial_ground",
},
],
safety: {
visualization_only: true,
navigation_or_safety_accepted: false,
},
};
const parsed = parseDatasetNativeScanPreview(payload);
assert.equal(parsed.pointCount, 2);
assert.deepEqual(parsed.groundTruthGround, [1, 0]);
const fetched = await fetchDatasetNativeScanPreview({
fetcher: async () => new Response(JSON.stringify(payload), { status: 200 }),
});
assert.equal(fetched.frameId, "frame-1");
payload.semantic_rgb_0_to_255.pop();
assert.throws(
() => parseDatasetNativeScanPreview(payload),
DatasetGatewayContractError,
);
});
test("decodes a point-aligned current-vs-ground-truth comparison", async () => {
const payload = {
schema_version: "missioncore.dataset-ground-comparison-preview/v1",
source_id: "goose-3d/v2025-08-22",
frame_id: "frame-1",
sampling: "deterministic-even-index",
point_count: 2,
current_ground: [1, 1],
ground_truth_ground: [1, 0],
evaluated: [1, 1],
disagreement: [0, 1],
metrics: {
true_positive: 1,
false_positive: 1,
false_negative: 0,
true_negative: 0,
precision: 0.5,
recall: 1,
f1: 2 / 3,
ground_iou: 0.5,
accuracy: 0.5,
artificial_ground_recall: 1,
natural_ground_recall: 0,
obstacle_non_ground_recall: 1,
},
latency_ms: 12.5,
provider: {
provider_id: "missioncore-local-percentile-ground/v1",
implementation_sha256: "a".repeat(64),
ground_truth: false,
},
safety: {
qualification_only: true,
navigation_or_safety_accepted: false,
},
};
const parsed = parseDatasetGroundComparison(payload);
assert.equal(parsed.metrics.groundIou, 0.5);
assert.deepEqual(parsed.disagreement, [0, 1]);
const fetched = await fetchDatasetGroundComparison({
fetcher: async () => new Response(JSON.stringify(payload), { status: 200 }),
});
assert.equal(fetched.latencyMs, 12.5);
payload.disagreement.pop();
assert.throws(
() => parseDatasetGroundComparison(payload),
DatasetGatewayContractError,
);
});