feat(lidar): admit and benchmark GOOSE baseline
This commit is contained in:
@@ -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 нормализован только для визуального просмотра 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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user