feat(lidar): add dataset gateway boundary
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
export type DatasetRepresentationId =
|
||||
| "native-scan"
|
||||
| "normalized-scan"
|
||||
| "rolling-local-map";
|
||||
|
||||
export interface DatasetGatewayCatalog {
|
||||
storage: {
|
||||
configured: boolean;
|
||||
admitted: boolean;
|
||||
status: "ready" | "blocked-storage-policy";
|
||||
requiredWindowsRoot: string;
|
||||
requiredWslRoot: string;
|
||||
};
|
||||
source: {
|
||||
sourceId: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
license: string;
|
||||
format: string;
|
||||
frameSemantics: "one-lidar-revolution";
|
||||
platforms: string[];
|
||||
superclasses: string[];
|
||||
validationArchiveGb: number;
|
||||
admissionStatus: "ready-for-download" | "blocked-storage-policy";
|
||||
};
|
||||
representations: Array<{
|
||||
id: DatasetRepresentationId;
|
||||
title: string;
|
||||
purpose: string;
|
||||
accumulation: boolean;
|
||||
}>;
|
||||
pipeline: Array<{
|
||||
stage: string;
|
||||
requires: string[];
|
||||
produces: string;
|
||||
}>;
|
||||
currentInput: {
|
||||
representation: "vendor-mapped-increment";
|
||||
nativeScan: false;
|
||||
perPointTime: false;
|
||||
ringOrLine: false;
|
||||
admittedForPatchworkpp: false;
|
||||
reason: string;
|
||||
};
|
||||
nextAction: string;
|
||||
}
|
||||
|
||||
export class DatasetGatewayContractError extends Error {}
|
||||
|
||||
type DatasetFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_ID = /^[a-z0-9][a-z0-9._:/-]{0,159}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new DatasetGatewayContractError(`${label}: ожидался объект`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new DatasetGatewayContractError(`${label}: ожидался массив`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function string(value: unknown, label: string, safe = false): string {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !value
|
||||
|| (safe && !SAFE_ID.test(value))
|
||||
) {
|
||||
throw new DatasetGatewayContractError(`${label}: некорректная строка`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): string[] {
|
||||
return array(value, label).map((item, index) =>
|
||||
string(item, `${label}[${index}]`, true)
|
||||
);
|
||||
}
|
||||
|
||||
function displayStrings(value: unknown, label: string): string[] {
|
||||
return array(value, label).map((item, index) =>
|
||||
string(item, `${label}[${index}]`)
|
||||
);
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new DatasetGatewayContractError(`${label}: ожидался boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new DatasetGatewayContractError(`${label}: некорректное число`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseDatasetGatewayCatalog(
|
||||
value: unknown,
|
||||
): DatasetGatewayCatalog {
|
||||
const source = record(value, "Dataset Gateway");
|
||||
if (
|
||||
source.schema_version !== "missioncore.dataset-gateway-catalog/v1"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Dataset Gateway contract несовместим");
|
||||
}
|
||||
const storage = record(source.storage, "storage");
|
||||
const storageStatus = storage.status;
|
||||
if (storageStatus !== "ready" && storageStatus !== "blocked-storage-policy") {
|
||||
throw new DatasetGatewayContractError("storage.status: неизвестное значение");
|
||||
}
|
||||
if (storage.path_exposed !== false) {
|
||||
throw new DatasetGatewayContractError("Dataset Gateway раскрыл локальный путь");
|
||||
}
|
||||
const sources = array(source.sources, "sources");
|
||||
if (sources.length !== 1) {
|
||||
throw new DatasetGatewayContractError("Ожидался один первичный dataset source");
|
||||
}
|
||||
const dataset = record(sources[0], "sources[0]");
|
||||
const download = record(dataset.download, "source.download");
|
||||
const admission = record(dataset.admission, "source.admission");
|
||||
if (download.automatic !== false) {
|
||||
throw new DatasetGatewayContractError("Большой dataset нельзя загружать автоматически");
|
||||
}
|
||||
const admissionStatus = admission.status;
|
||||
if (
|
||||
admissionStatus !== "ready-for-download"
|
||||
&& admissionStatus !== "blocked-storage-policy"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("source admission status неизвестен");
|
||||
}
|
||||
const representations = array(
|
||||
source.representations,
|
||||
"representations",
|
||||
).map((value, index) => {
|
||||
const item = record(value, `representations[${index}]`);
|
||||
const id = item.id;
|
||||
if (
|
||||
id !== "native-scan"
|
||||
&& id !== "normalized-scan"
|
||||
&& id !== "rolling-local-map"
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Неизвестная LiDAR representation");
|
||||
}
|
||||
const normalizedId: DatasetRepresentationId = id;
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: string(item.title, "representation.title"),
|
||||
purpose: string(item.purpose, "representation.purpose", true),
|
||||
accumulation: boolean(item.accumulation, "representation.accumulation"),
|
||||
};
|
||||
});
|
||||
const pipeline = array(source.pipeline, "pipeline").map((value, index) => {
|
||||
const item = record(value, `pipeline[${index}]`);
|
||||
return {
|
||||
stage: string(item.stage, "pipeline.stage", true),
|
||||
requires: strings(item.requires, "pipeline.requires"),
|
||||
produces: string(item.produces, "pipeline.produces", true),
|
||||
};
|
||||
});
|
||||
const inputs = array(source.known_inputs, "known_inputs");
|
||||
const currentInput = record(inputs[0], "known_inputs[0]");
|
||||
if (
|
||||
currentInput.representation !== "vendor-mapped-increment"
|
||||
|| currentInput.native_scan !== false
|
||||
|| currentInput.per_point_time !== false
|
||||
|| currentInput.ring_or_line !== false
|
||||
|| currentInput.admitted_for_patchworkpp !== false
|
||||
) {
|
||||
throw new DatasetGatewayContractError("Vendor-map boundary завышен");
|
||||
}
|
||||
if (dataset.frame_semantics !== "one-lidar-revolution") {
|
||||
throw new DatasetGatewayContractError("GOOSE frame semantics несовместима");
|
||||
}
|
||||
return {
|
||||
storage: {
|
||||
configured: boolean(storage.configured, "storage.configured"),
|
||||
admitted: boolean(storage.admitted, "storage.admitted"),
|
||||
status: storageStatus,
|
||||
requiredWindowsRoot: string(
|
||||
storage.required_windows_root,
|
||||
"storage.required_windows_root",
|
||||
),
|
||||
requiredWslRoot: string(storage.required_wsl_root, "storage.required_wsl_root"),
|
||||
},
|
||||
source: {
|
||||
sourceId: string(dataset.source_id, "source_id", true),
|
||||
displayName: string(dataset.display_name, "display_name"),
|
||||
role: string(dataset.role, "role", true),
|
||||
license: string(dataset.license, "license"),
|
||||
format: string(dataset.format, "format", true),
|
||||
frameSemantics: "one-lidar-revolution",
|
||||
platforms: displayStrings(dataset.platforms, "platforms"),
|
||||
superclasses: strings(dataset.superclasses, "superclasses"),
|
||||
validationArchiveGb: number(
|
||||
download.validation_archive_gb,
|
||||
"validation_archive_gb",
|
||||
),
|
||||
admissionStatus,
|
||||
},
|
||||
representations,
|
||||
pipeline,
|
||||
currentInput: {
|
||||
representation: "vendor-mapped-increment",
|
||||
nativeScan: false,
|
||||
perPointTime: false,
|
||||
ringOrLine: false,
|
||||
admittedForPatchworkpp: false,
|
||||
reason: string(currentInput.reason, "known_inputs.reason", true),
|
||||
},
|
||||
nextAction: string(source.next_action, "next_action", true),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response): Promise<unknown> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Dataset Gateway HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchDatasetGatewayCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: DatasetFetch } = {},
|
||||
): Promise<DatasetGatewayCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/lidar/dataset-gateway", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseDatasetGatewayCatalog(await responseJson(response));
|
||||
}
|
||||
@@ -137,6 +137,17 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dataset-gateway__representations,
|
||||
.dataset-gateway__grid,
|
||||
.dataset-gateway__footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dataset-gateway__representations > div + div {
|
||||
border-top: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.control-station .nodedc-header__profile-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -807,6 +807,144 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dataset-gateway {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--nodedc-accent) 24%, transparent);
|
||||
background:
|
||||
radial-gradient(circle at 10% 0%, color-mix(in srgb, var(--nodedc-accent) 10%, transparent), transparent 32%),
|
||||
rgb(255 255 255 / 0.025);
|
||||
}
|
||||
|
||||
.dataset-gateway__heading p,
|
||||
.dataset-gateway__grid p,
|
||||
.dataset-gateway__pending {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dataset-gateway__representations {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
.dataset-gateway__representations > div {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 7.4rem;
|
||||
align-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 1rem 1.1rem 1rem 3.6rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
|
||||
.dataset-gateway__representations > div + div {
|
||||
border-left: 1px solid rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.dataset-gateway__representations > div > span {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
color: var(--nodedc-accent);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.dataset-gateway__representations strong,
|
||||
.dataset-gateway__representations small,
|
||||
.dataset-gateway__representations em {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dataset-gateway__representations strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.dataset-gateway__representations small,
|
||||
.dataset-gateway__representations em {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dataset-gateway__representations em {
|
||||
color: color-mix(in srgb, var(--nodedc-accent) 72%, white);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dataset-gateway__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.dataset-gateway__grid > section {
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(255 255 255 / 0.02);
|
||||
}
|
||||
|
||||
.dataset-gateway__grid > section[data-warning="true"] {
|
||||
border-color: rgb(255 181 71 / 0.2);
|
||||
}
|
||||
|
||||
.dataset-gateway__grid h3 {
|
||||
margin: 0.28rem 0 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dataset-gateway__facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.dataset-gateway__facts span {
|
||||
padding: 0.32rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
.dataset-gateway__footer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
gap: 1rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid rgb(255 255 255 / 0.07);
|
||||
}
|
||||
|
||||
.dataset-gateway__footer > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dataset-gateway__footer span,
|
||||
.dataset-gateway__footer small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.dataset-gateway__footer strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.lidar-quality-message {
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchDatasetGatewayCatalog,
|
||||
type DatasetGatewayCatalog,
|
||||
} from "../core/lidar/datasetGateway";
|
||||
|
||||
const representationLabels: Record<string, string> = {
|
||||
"native-scan": "Один оборот / скан",
|
||||
"normalized-scan": "Deskew + bounded cleanup",
|
||||
"rolling-local-map": "Pose + TTL + voxel map",
|
||||
};
|
||||
|
||||
export function DatasetGatewayPanel() {
|
||||
const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchDatasetGatewayCatalog({ signal: controller.signal })
|
||||
.then((value) => {
|
||||
if (!controller.signal.aborted) setCatalog(value);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: "Dataset Gateway недоступен",
|
||||
);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GlassSurface className="dataset-gateway" padding="lg">
|
||||
<header className="panel-heading dataset-gateway__heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">DATASET GATEWAY · S0</span>
|
||||
<h2>Три разных LiDAR-продукта</h2>
|
||||
<p>
|
||||
Кольцевой одиночный скан, очищенный sensor-frame и накопленная карта
|
||||
больше не считаются одним облаком.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={error ? "danger" : catalog?.storage.admitted ? "success" : "warning"}
|
||||
>
|
||||
{error
|
||||
? "Gateway недоступен"
|
||||
: catalog?.storage.admitted
|
||||
? "D: допущен"
|
||||
: "Ожидает D:"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{catalog ? (
|
||||
<>
|
||||
<div className="dataset-gateway__representations">
|
||||
{catalog.representations.map((representation, index) => (
|
||||
<div key={representation.id}>
|
||||
<span>0{index + 1}</span>
|
||||
<strong>
|
||||
{representationLabels[representation.id] ?? representation.title}
|
||||
</strong>
|
||||
<small>{representation.title}</small>
|
||||
<em>
|
||||
{representation.accumulation
|
||||
? "накопление включено явно"
|
||||
: "без накопления"}
|
||||
</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dataset-gateway__grid">
|
||||
<section>
|
||||
<span className="section-eyebrow">ПЕРВЫЙ BASELINE</span>
|
||||
<h3>{catalog.source.displayName}</h3>
|
||||
<p>
|
||||
Один оборот VLS-128 в SemanticKITTI XYZI + point-wise semantic и
|
||||
instance labels. Это то самое разреженное кольцевое облако,
|
||||
которое корректно сравнивать с алгоритмами.
|
||||
</p>
|
||||
<div className="dataset-gateway__facts">
|
||||
<span>{catalog.source.platforms.join(" · ")}</span>
|
||||
<span>{catalog.source.superclasses.length} superclasses</span>
|
||||
<span>val {catalog.source.validationArchiveGb} ГБ</span>
|
||||
<span>{catalog.source.license}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section data-warning="true">
|
||||
<span className="section-eyebrow">ТЕКУЩИЙ DEVICE INPUT</span>
|
||||
<h3>Mapped feed ≠ raw scan</h3>
|
||||
<p>
|
||||
Внешний MQTT содержит vendor-mapped increment после LIO. В нём
|
||||
нет per-point time и line/ring, поэтому из него нельзя честно
|
||||
восстановить один исходный скан или выполнить deskew.
|
||||
</p>
|
||||
<div className="dataset-gateway__facts">
|
||||
<span>Patchwork++: diagnostic only</span>
|
||||
<span>rolling map: возможно</span>
|
||||
<span>raw reconstruction: невозможно</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="dataset-gateway__footer">
|
||||
<div>
|
||||
<span>Storage gate</span>
|
||||
<strong>{catalog.storage.requiredWindowsRoot}</strong>
|
||||
<small>{catalog.storage.requiredWslRoot}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Следующий исполнимый шаг</span>
|
||||
<strong>
|
||||
{catalog.storage.admitted
|
||||
? "Скачать GOOSE validation и импортировать первый кадр"
|
||||
: "Подключить Dataset Root на D: worker"}
|
||||
</strong>
|
||||
<small>Автозагрузка 3.3 ГБ намеренно запрещена</small>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
<p className="dataset-gateway__pending">
|
||||
{error ?? "Читаем входные контракты Dataset Gateway…"}
|
||||
</p>
|
||||
)}
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
import { DatasetGatewayPanel } from "./DatasetGatewayPanel";
|
||||
|
||||
function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
@@ -220,6 +221,8 @@ export function LidarQualityWorkspace({
|
||||
<span className="workspace-lead__note">Только проверенные replay-артефакты</span>
|
||||
</section>
|
||||
|
||||
<DatasetGatewayPanel />
|
||||
|
||||
{loading && !detail ? (
|
||||
<GlassSurface className="lidar-quality-message" padding="lg">
|
||||
<StatusBadge tone="accent">Проверка evidence</StatusBadge>
|
||||
|
||||
Reference in New Issue
Block a user