feat: add passive K1 local surface replay
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
export interface LidarLocalSurfaceDistribution {
|
||||
sampleCount: number;
|
||||
minimum: number | null;
|
||||
mean: number | null;
|
||||
p50: number | null;
|
||||
p95: number | null;
|
||||
maximum: number | null;
|
||||
}
|
||||
|
||||
export interface LidarLocalSurfaceAnchor {
|
||||
key: string;
|
||||
label: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface LidarLocalSurfaceModel {
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
sessionId: string;
|
||||
sourcePackId: string;
|
||||
status: "diagnostic-only";
|
||||
source: {
|
||||
frameCount: number;
|
||||
availableLidarFrames: number;
|
||||
pointCount: number;
|
||||
timelineStartSeconds: number;
|
||||
timelineEndSeconds: number;
|
||||
immutable: true;
|
||||
passiveProcessingOnly: true;
|
||||
firmwareOrDeviceCommandsUsed: false;
|
||||
};
|
||||
metrics: {
|
||||
frames: {
|
||||
total: number;
|
||||
sourceAvailable: number;
|
||||
valid: number;
|
||||
sourceUnavailable: number;
|
||||
poseStale: number;
|
||||
insufficientSurface: number;
|
||||
fitFailed: number;
|
||||
};
|
||||
sensorHeightM: LidarLocalSurfaceDistribution;
|
||||
slopeDeg: LidarLocalSurfaceDistribution;
|
||||
roughnessM: LidarLocalSurfaceDistribution;
|
||||
confidence: LidarLocalSurfaceDistribution;
|
||||
poseBindingAgeMs: LidarLocalSurfaceDistribution;
|
||||
surfaceMaxAgeMs: LidarLocalSurfaceDistribution;
|
||||
};
|
||||
anchors: LidarLocalSurfaceAnchor[];
|
||||
occupancyPolicy: {
|
||||
absenceOfPointsMeansFree: false;
|
||||
unknownIsTraversable: false;
|
||||
persistentReconstructionMutated: false;
|
||||
dynamicObjectLayerAvailable: false;
|
||||
};
|
||||
createdAtUtc: string | null;
|
||||
groundTruth: false;
|
||||
authority: {
|
||||
commandsEnabled: false;
|
||||
navigationOrSafetyAccepted: false;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LidarLocalSurfaceCatalog {
|
||||
configured: boolean;
|
||||
validTotal: number;
|
||||
invalidTotal: number;
|
||||
items: LidarLocalSurfaceModel[];
|
||||
}
|
||||
|
||||
export interface LidarLocalSurfaceFrame {
|
||||
modelId: string;
|
||||
sourcePackId: string;
|
||||
sessionId: string;
|
||||
frameIndex: number;
|
||||
frameCount: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
sourceAvailable: boolean;
|
||||
valid: boolean;
|
||||
failureCode: number;
|
||||
pointCount: number;
|
||||
coordinateFrame: "map";
|
||||
distanceUnit: "m";
|
||||
pointsXyzM: Array<[number, number, number]>;
|
||||
pointClass: number[];
|
||||
pointHeightM: number[];
|
||||
pose: {
|
||||
positionXyzM: [number, number, number];
|
||||
orientationXyzw: [number, number, number, number];
|
||||
bindingAgeMs: number;
|
||||
};
|
||||
surface: {
|
||||
planeCoefficientsMap: [number, number, number, number];
|
||||
sensorHeightM: number;
|
||||
slopeDeg: number;
|
||||
roughnessM: number;
|
||||
confidence: number;
|
||||
surfaceMaxAgeMs: number;
|
||||
cellCount: number;
|
||||
inlierCellCount: number;
|
||||
};
|
||||
counts: {
|
||||
classified: number;
|
||||
surface: number;
|
||||
occupied: number;
|
||||
belowSurface: number;
|
||||
};
|
||||
occupancyPolicy: LidarLocalSurfaceModel["occupancyPolicy"];
|
||||
groundTruth: false;
|
||||
authority: LidarLocalSurfaceModel["authority"];
|
||||
}
|
||||
|
||||
export class LidarLocalSurfaceContractError extends Error {}
|
||||
|
||||
export class LidarLocalSurfaceApiError extends Error {
|
||||
constructor(message: string, readonly status: number | null = null) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type LidarFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const LOCAL_SURFACE_SCHEMA_PREFIX = ["missioncore.", "k", "1", "-local-surface"].join("");
|
||||
const LOCAL_SURFACE_MODEL_PREFIX = ["k", "1", "-local-surface-"].join("");
|
||||
const SAFE_MODEL_ID = new RegExp(`^${LOCAL_SURFACE_MODEL_PREFIX}[a-f0-9]{64}$`);
|
||||
const SAFE_PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
|
||||
const SAFE_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: ожидался объект`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: ожидался массив`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(
|
||||
value: unknown,
|
||||
label: string,
|
||||
pattern?: RegExp,
|
||||
): string {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !value.trim()
|
||||
|| value.length > 240
|
||||
|| (pattern && !pattern.test(value))
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: некорректная строка`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: ожидалось целое число`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: ожидалось конечное число`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: ожидался boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function tuple(
|
||||
value: unknown,
|
||||
length: number,
|
||||
label: string,
|
||||
): number[] {
|
||||
const values = array(value, label).map((item, index) =>
|
||||
finite(item, `${label}[${index}]`)
|
||||
);
|
||||
if (values.length !== length) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: неверная длина`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): LidarLocalSurfaceDistribution {
|
||||
const source = record(value, label);
|
||||
const sampleCount = integer(source.sample_count, `${label}.sample_count`);
|
||||
const metric = (key: string): number | null => {
|
||||
const item = source[key];
|
||||
if (item === null) return null;
|
||||
return finite(item, `${label}.${key}`);
|
||||
};
|
||||
const result = {
|
||||
sampleCount,
|
||||
minimum: metric("minimum"),
|
||||
mean: metric("mean"),
|
||||
p50: metric("p50"),
|
||||
p95: metric("p95"),
|
||||
maximum: metric("maximum"),
|
||||
};
|
||||
if (
|
||||
(sampleCount === 0
|
||||
&& Object.entries(result).some(
|
||||
([key, item]) => key !== "sampleCount" && item !== null,
|
||||
))
|
||||
|| (sampleCount > 0
|
||||
&& Object.entries(result).some(
|
||||
([key, item]) => key !== "sampleCount" && item === null,
|
||||
))
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: несовместимая выборка`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function occupancyPolicy(
|
||||
value: unknown,
|
||||
): LidarLocalSurfaceModel["occupancyPolicy"] {
|
||||
const source = record(value, "occupancy_policy");
|
||||
if (
|
||||
source.absence_of_points_means_free !== false
|
||||
|| source.unknown_is_traversable !== false
|
||||
|| source.persistent_reconstruction_mutated !== false
|
||||
|| source.dynamic_object_layer_available !== false
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Local-surface policy завышает доступное знание",
|
||||
);
|
||||
}
|
||||
return {
|
||||
absenceOfPointsMeansFree: false,
|
||||
unknownIsTraversable: false,
|
||||
persistentReconstructionMutated: false,
|
||||
dynamicObjectLayerAvailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function authority(
|
||||
value: unknown,
|
||||
): LidarLocalSurfaceModel["authority"] {
|
||||
const source = record(value, "authority");
|
||||
if (
|
||||
source.commands_enabled !== false
|
||||
|| source.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Local-surface authority несовместим",
|
||||
);
|
||||
}
|
||||
return {
|
||||
commandsEnabled: false,
|
||||
navigationOrSafetyAccepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function anchor(value: unknown): LidarLocalSurfaceAnchor {
|
||||
const source = record(value, "anchor");
|
||||
return {
|
||||
key: text(source.key, "anchor.key", SAFE_KEY),
|
||||
label: text(source.label, "anchor.label"),
|
||||
frameIndex: integer(source.frame_index, "anchor.frame_index"),
|
||||
sourceFrameIndex: integer(
|
||||
source.source_frame_index,
|
||||
"anchor.source_frame_index",
|
||||
),
|
||||
sessionSeconds: finite(source.session_seconds, "anchor.session_seconds"),
|
||||
valid: boolean(source.valid, "anchor.valid"),
|
||||
};
|
||||
}
|
||||
|
||||
function model(value: unknown): LidarLocalSurfaceModel {
|
||||
const source = record(value, "LiDAR local-surface model");
|
||||
const sourceEvidence = record(source.source, "source");
|
||||
const metrics = record(source.metrics, "metrics");
|
||||
const frames = record(metrics.frames, "metrics.frames");
|
||||
if (
|
||||
source.status !== "diagnostic-only"
|
||||
|| source.ground_truth !== false
|
||||
|| sourceEvidence.immutable !== true
|
||||
|| sourceEvidence.passive_processing_only !== true
|
||||
|| sourceEvidence.firmware_or_device_commands_used !== false
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface меняет источник или завышает статус",
|
||||
);
|
||||
}
|
||||
const frameMetrics = {
|
||||
total: integer(frames.total, "frames.total"),
|
||||
sourceAvailable: integer(frames.source_available, "frames.source_available"),
|
||||
valid: integer(frames.valid, "frames.valid"),
|
||||
sourceUnavailable: integer(
|
||||
frames.source_unavailable,
|
||||
"frames.source_unavailable",
|
||||
),
|
||||
poseStale: integer(frames.pose_stale, "frames.pose_stale"),
|
||||
insufficientSurface: integer(
|
||||
frames.insufficient_surface,
|
||||
"frames.insufficient_surface",
|
||||
),
|
||||
fitFailed: integer(frames.fit_failed, "frames.fit_failed"),
|
||||
};
|
||||
if (
|
||||
frameMetrics.sourceAvailable + frameMetrics.sourceUnavailable
|
||||
!== frameMetrics.total
|
||||
|| frameMetrics.valid > frameMetrics.sourceAvailable
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface frame totals расходятся",
|
||||
);
|
||||
}
|
||||
const anchors = array(source.anchors, "anchors").map(anchor);
|
||||
if (!anchors.length || anchors.some((item) => item.frameIndex >= frameMetrics.total)) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface anchors несовместимы",
|
||||
);
|
||||
}
|
||||
return {
|
||||
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
||||
displayName: text(source.display_name, "display_name"),
|
||||
sessionId: text(source.session_id, "session_id", SAFE_ID),
|
||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||
status: "diagnostic-only",
|
||||
source: {
|
||||
frameCount: integer(sourceEvidence.frame_count, "source.frame_count"),
|
||||
availableLidarFrames: integer(
|
||||
sourceEvidence.available_lidar_frames,
|
||||
"source.available_lidar_frames",
|
||||
),
|
||||
pointCount: integer(sourceEvidence.point_count, "source.point_count"),
|
||||
timelineStartSeconds: finite(
|
||||
sourceEvidence.timeline_start_seconds,
|
||||
"source.timeline_start_seconds",
|
||||
),
|
||||
timelineEndSeconds: finite(
|
||||
sourceEvidence.timeline_end_seconds,
|
||||
"source.timeline_end_seconds",
|
||||
),
|
||||
immutable: true,
|
||||
passiveProcessingOnly: true,
|
||||
firmwareOrDeviceCommandsUsed: false,
|
||||
},
|
||||
metrics: {
|
||||
frames: frameMetrics,
|
||||
sensorHeightM: distribution(metrics.sensor_height_m, "sensor_height_m"),
|
||||
slopeDeg: distribution(metrics.slope_deg, "slope_deg"),
|
||||
roughnessM: distribution(metrics.roughness_m, "roughness_m"),
|
||||
confidence: distribution(metrics.confidence, "confidence"),
|
||||
poseBindingAgeMs: distribution(
|
||||
metrics.pose_binding_age_ms,
|
||||
"pose_binding_age_ms",
|
||||
),
|
||||
surfaceMaxAgeMs: distribution(
|
||||
metrics.surface_max_age_ms,
|
||||
"surface_max_age_ms",
|
||||
),
|
||||
},
|
||||
anchors,
|
||||
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
|
||||
createdAtUtc:
|
||||
source.created_at_utc === null || source.created_at_utc === undefined
|
||||
? null
|
||||
: text(source.created_at_utc, "created_at_utc"),
|
||||
groundTruth: false,
|
||||
authority: authority(source.authority),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLidarLocalSurfaceCatalog(
|
||||
value: unknown,
|
||||
): LidarLocalSurfaceCatalog {
|
||||
const source = record(value, "LiDAR local-surface catalog");
|
||||
if (
|
||||
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-catalog/v1`
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface catalog несовместим",
|
||||
);
|
||||
}
|
||||
return {
|
||||
configured: boolean(source.configured, "configured"),
|
||||
validTotal: integer(source.valid_total, "valid_total"),
|
||||
invalidTotal: integer(source.invalid_total, "invalid_total"),
|
||||
items: array(source.items, "items").map(model),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLidarLocalSurfaceFrame(
|
||||
value: unknown,
|
||||
): LidarLocalSurfaceFrame {
|
||||
const source = record(value, "LiDAR local-surface frame");
|
||||
if (
|
||||
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-frame/v1`
|
||||
|| source.access !== "read-only"
|
||||
|| source.ground_truth !== false
|
||||
|| source.coordinate_frame !== "map"
|
||||
|| source.distance_unit !== "m"
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface frame несовместим",
|
||||
);
|
||||
}
|
||||
const pointCount = integer(source.point_count, "point_count");
|
||||
if (pointCount > 200_000) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface frame слишком большой",
|
||||
);
|
||||
}
|
||||
const pointsXyzM = array(source.points_xyz_m, "points_xyz_m").map(
|
||||
(item, index): [number, number, number] => {
|
||||
const values = tuple(item, 3, `points_xyz_m[${index}]`);
|
||||
return [values[0], values[1], values[2]];
|
||||
},
|
||||
);
|
||||
const pointClass = array(source.point_class, "point_class").map(
|
||||
(item, index) => {
|
||||
const value = integer(item, `point_class[${index}]`);
|
||||
if (value > 3) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Неизвестный local-surface class",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
);
|
||||
const pointHeightM = array(source.point_height_m, "point_height_m").map(
|
||||
(item, index) => finite(item, `point_height_m[${index}]`),
|
||||
);
|
||||
if (
|
||||
pointsXyzM.length !== pointCount
|
||||
|| pointClass.length !== pointCount
|
||||
|| pointHeightM.length !== pointCount
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface point arrays расходятся",
|
||||
);
|
||||
}
|
||||
const pose = record(source.pose, "pose");
|
||||
const surface = record(source.surface, "surface");
|
||||
const counts = record(source.counts, "counts");
|
||||
const parsedCounts = {
|
||||
classified: integer(counts.classified, "counts.classified"),
|
||||
surface: integer(counts.surface, "counts.surface"),
|
||||
occupied: integer(counts.occupied, "counts.occupied"),
|
||||
belowSurface: integer(counts.below_surface, "counts.below_surface"),
|
||||
};
|
||||
if (
|
||||
parsedCounts.classified !== pointClass.filter((item) => item !== 0).length
|
||||
|| parsedCounts.surface !== pointClass.filter((item) => item === 1).length
|
||||
|| parsedCounts.occupied !== pointClass.filter((item) => item === 2).length
|
||||
|| parsedCounts.belowSurface
|
||||
!== pointClass.filter((item) => item === 3).length
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface counts расходятся",
|
||||
);
|
||||
}
|
||||
const position = tuple(pose.position_xyz_m, 3, "pose.position_xyz_m");
|
||||
const orientation = tuple(
|
||||
pose.orientation_xyzw,
|
||||
4,
|
||||
"pose.orientation_xyzw",
|
||||
);
|
||||
const plane = tuple(
|
||||
surface.plane_coefficients_map,
|
||||
4,
|
||||
"surface.plane_coefficients_map",
|
||||
);
|
||||
return {
|
||||
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||
sessionId: text(source.session_id, "session_id", SAFE_ID),
|
||||
frameIndex: integer(source.frame_index, "frame_index"),
|
||||
frameCount: integer(source.frame_count, "frame_count"),
|
||||
sourceFrameIndex: integer(source.source_frame_index, "source_frame_index"),
|
||||
sessionSeconds: finite(source.session_seconds, "session_seconds"),
|
||||
sourceAvailable: boolean(source.source_available, "source_available"),
|
||||
valid: boolean(source.valid, "valid"),
|
||||
failureCode: integer(source.failure_code, "failure_code"),
|
||||
pointCount,
|
||||
coordinateFrame: "map",
|
||||
distanceUnit: "m",
|
||||
pointsXyzM,
|
||||
pointClass,
|
||||
pointHeightM,
|
||||
pose: {
|
||||
positionXyzM: [position[0], position[1], position[2]],
|
||||
orientationXyzw: [
|
||||
orientation[0],
|
||||
orientation[1],
|
||||
orientation[2],
|
||||
orientation[3],
|
||||
],
|
||||
bindingAgeMs: finite(pose.binding_age_ms, "pose.binding_age_ms"),
|
||||
},
|
||||
surface: {
|
||||
planeCoefficientsMap: [plane[0], plane[1], plane[2], plane[3]],
|
||||
sensorHeightM: finite(surface.sensor_height_m, "surface.sensor_height_m"),
|
||||
slopeDeg: finite(surface.slope_deg, "surface.slope_deg"),
|
||||
roughnessM: finite(surface.roughness_m, "surface.roughness_m"),
|
||||
confidence: finite(surface.confidence, "surface.confidence"),
|
||||
surfaceMaxAgeMs: finite(
|
||||
surface.surface_max_age_ms,
|
||||
"surface.surface_max_age_ms",
|
||||
),
|
||||
cellCount: integer(surface.cell_count, "surface.cell_count"),
|
||||
inlierCellCount: integer(
|
||||
surface.inlier_cell_count,
|
||||
"surface.inlier_cell_count",
|
||||
),
|
||||
},
|
||||
counts: parsedCounts,
|
||||
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
|
||||
groundTruth: false,
|
||||
authority: authority(source.authority),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail =
|
||||
payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new LidarLocalSurfaceApiError(detail, response.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function fetchLidarLocalSurfaces(
|
||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||
): Promise<LidarLocalSurfaceCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/lidar/local-surfaces?limit=10", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseLidarLocalSurfaceCatalog(
|
||||
await responseJson(response, "Не удалось получить LiDAR local-surface."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLidarLocalSurfaceFrame(
|
||||
modelId: string,
|
||||
frameIndex: number,
|
||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||
): Promise<LidarLocalSurfaceFrame> {
|
||||
if (
|
||||
!SAFE_MODEL_ID.test(modelId)
|
||||
|| !Number.isSafeInteger(frameIndex)
|
||||
|| frameIndex < 0
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Некорректный LiDAR local-surface frame",
|
||||
);
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/lidar/local-surfaces/${modelId}/frames/${frameIndex}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseLidarLocalSurfaceFrame(
|
||||
await responseJson(
|
||||
response,
|
||||
"Не удалось получить LiDAR local-surface frame.",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,14 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lidar-local-surface__summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.lidar-local-surface__stage {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(13rem, 0.32fr);
|
||||
}
|
||||
|
||||
.lidar-field-cloud {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
@@ -412,6 +420,20 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lidar-local-surface__heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lidar-local-surface__summary,
|
||||
.lidar-local-surface__stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lidar-local-surface__stage .lidar-ground-scene,
|
||||
.lidar-local-surface__stage .lidar-ground-scene-placeholder {
|
||||
min-height: 22rem;
|
||||
}
|
||||
|
||||
.polygon-run-identity dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -2821,6 +2821,146 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lidar-local-surface {
|
||||
display: grid;
|
||||
gap: 0.72rem;
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__heading h3,
|
||||
.lidar-local-surface__heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lidar-local-surface__heading h3 {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__heading p,
|
||||
.lidar-local-surface__frame p {
|
||||
max-width: 48rem;
|
||||
margin-top: 0.28rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.lidar-local-surface__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__summary > div {
|
||||
display: grid;
|
||||
gap: 0.24rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.62rem 0.68rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__summary span,
|
||||
.lidar-local-surface__frame span,
|
||||
.lidar-local-surface__frame small,
|
||||
.lidar-local-surface__frame dt,
|
||||
.lidar-local-surface__legend {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__summary strong,
|
||||
.lidar-local-surface__frame strong,
|
||||
.lidar-local-surface__frame dd {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__stage {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(14rem, 0.24fr);
|
||||
min-height: 30rem;
|
||||
border-radius: 0.9rem;
|
||||
background: #06070a;
|
||||
}
|
||||
|
||||
.lidar-local-surface__stage .lidar-ground-scene,
|
||||
.lidar-local-surface__stage .lidar-ground-scene-placeholder {
|
||||
min-height: 30rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.lidar-local-surface__frame {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.85rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__frame > div:first-child {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__frame dl {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lidar-local-surface__frame dl > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__frame dt,
|
||||
.lidar-local-surface__frame dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend i {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
background: #454a47;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend i[data-class="surface"] {
|
||||
background: #a1b87d;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend i[data-class="occupied"] {
|
||||
background: #f0783d;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend i[data-class="below"] {
|
||||
background: #a85061;
|
||||
}
|
||||
|
||||
.lidar-fallback-review {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -9,7 +9,8 @@ export type LidarGroundViewMode =
|
||||
| "disagreement"
|
||||
| "candidate-disagreement"
|
||||
| "semantic"
|
||||
| "ground-truth";
|
||||
| "ground-truth"
|
||||
| "local-surface";
|
||||
|
||||
export interface LidarGroundPointCloudFrame {
|
||||
pointCount: number;
|
||||
@@ -25,6 +26,7 @@ export interface LidarGroundPointCloudFrame {
|
||||
candidateDisagreement?: number[];
|
||||
groundTruthGround?: number[];
|
||||
evaluationMask?: number[];
|
||||
localSurfaceClass?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +69,18 @@ function frameColors(
|
||||
const current = frame.masks.currentGround[index] === 1;
|
||||
const candidate = frame.masks.candidateGround[index] === 1;
|
||||
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||
if (mode === "intensity") {
|
||||
if (mode === "local-surface") {
|
||||
const localClass = frame.masks.localSurfaceClass?.[index] ?? 0;
|
||||
if (localClass === 1) {
|
||||
setRgb(colors, offset, 0.63, 0.72, 0.49);
|
||||
} else if (localClass === 2) {
|
||||
setRgb(colors, offset, 0.94, 0.48, 0.24);
|
||||
} else if (localClass === 3) {
|
||||
setRgb(colors, offset, 0.66, 0.32, 0.38);
|
||||
} else {
|
||||
setRgb(colors, offset, 0.27, 0.29, 0.28);
|
||||
}
|
||||
} else if (mode === "intensity") {
|
||||
const intensity = (frame.intensity0To255?.[index] ?? 96) / 255;
|
||||
const neutral = 0.16 + intensity * 0.8;
|
||||
setRgb(
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchLidarLocalSurfaceFrame,
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceFrame,
|
||||
type LidarLocalSurfaceModel,
|
||||
} from "../core/lidar/localSurface";
|
||||
import { LidarGroundPointCloud } from "./LidarGroundPointCloud";
|
||||
|
||||
function formatNumber(value: number | null, digits = 2): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Не удалось открыть локальную модель LiDAR.";
|
||||
}
|
||||
|
||||
export function LidarLocalSurfacePanel({
|
||||
selectedWindowKey,
|
||||
reloadGeneration,
|
||||
}: {
|
||||
selectedWindowKey: string | null;
|
||||
reloadGeneration: number;
|
||||
}) {
|
||||
const [model, setModel] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [frame, setFrame] = useState<LidarLocalSurfaceFrame | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const anchor = useMemo(() => {
|
||||
if (!model) return null;
|
||||
return (
|
||||
model.anchors.find((item) => item.key === selectedWindowKey)
|
||||
?? model.anchors[0]
|
||||
?? null
|
||||
);
|
||||
}, [model, selectedWindowKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchLidarLocalSurfaces({ signal: controller.signal })
|
||||
.then((catalog) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setModel(catalog.items[0] ?? null);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setModel(null);
|
||||
setFrame(null);
|
||||
setError(errorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [reloadGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model || !anchor) {
|
||||
setFrame(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchLidarLocalSurfaceFrame(model.modelId, anchor.frameIndex, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((nextFrame) => {
|
||||
if (!controller.signal.aborted) setFrame(nextFrame);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFrame(null);
|
||||
setError(errorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [anchor, model]);
|
||||
|
||||
const cloudFrame = useMemo(() => {
|
||||
if (!frame) return null;
|
||||
const emptyMask = new Array<number>(frame.pointCount).fill(0);
|
||||
return {
|
||||
pointCount: frame.pointCount,
|
||||
pointsXyzM: frame.pointsXyzM,
|
||||
intensity0To255: null,
|
||||
masks: {
|
||||
currentGround: emptyMask,
|
||||
currentAssigned: emptyMask,
|
||||
candidateGround: emptyMask,
|
||||
candidateAssigned: emptyMask,
|
||||
disagreement: emptyMask,
|
||||
localSurfaceClass: frame.pointClass,
|
||||
},
|
||||
};
|
||||
}, [frame]);
|
||||
|
||||
if (!model && !loading && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="lidar-local-surface"
|
||||
aria-label="Динамическая локальная поверхность LiDAR"
|
||||
>
|
||||
<header className="lidar-local-surface__heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНАЯ МОДЕЛЬ LIDAR · L2.6</span>
|
||||
<h3>Поверхность и наблюдаемые препятствия</h3>
|
||||
<p>
|
||||
Производная от неизменяемого RAVNOVES00: высота и уклон
|
||||
вычисляются из текущей позы и локальной поверхности, без константы
|
||||
1,27 м и без команд в сканер.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={error ? "danger" : frame?.valid ? "success" : "warning"}>
|
||||
{error
|
||||
? "Недоступно"
|
||||
: frame?.valid
|
||||
? "Кадр рассчитан"
|
||||
: "Диагностический режим"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{model ? (
|
||||
<div className="lidar-local-surface__summary">
|
||||
<div>
|
||||
<span>Покрытие записи</span>
|
||||
<strong>
|
||||
{model.metrics.frames.valid.toLocaleString("ru-RU")} /{" "}
|
||||
{model.metrics.frames.sourceAvailable.toLocaleString("ru-RU")}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Высота над поверхностью · p50</span>
|
||||
<strong>{formatNumber(model.metrics.sensorHeightM.p50)} м</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Шероховатость · p95</span>
|
||||
<strong>{formatNumber(model.metrics.roughnessM.p95, 3)} м</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Pose binding · p95</span>
|
||||
<strong>{formatNumber(model.metrics.poseBindingAgeMs.p95)} мс</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lidar-local-surface__stage">
|
||||
{cloudFrame && frame ? (
|
||||
<LidarGroundPointCloud frame={cloudFrame} mode="local-surface" />
|
||||
) : (
|
||||
<div className="lidar-ground-scene-placeholder">
|
||||
<StatusBadge tone={error ? "danger" : "accent"}>
|
||||
{error ? "Ошибка" : "Загрузка"}
|
||||
</StatusBadge>
|
||||
<span>{error ?? "Читаем производную выбранной сцены…"}</span>
|
||||
</div>
|
||||
)}
|
||||
{frame ? (
|
||||
<aside className="lidar-local-surface__frame">
|
||||
<div>
|
||||
<span>Исходный кадр</span>
|
||||
<strong>{frame.sourceFrameIndex}</strong>
|
||||
<small>t = {formatNumber(frame.sessionSeconds)} с</small>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Высота</dt>
|
||||
<dd>{formatNumber(frame.surface.sensorHeightM)} м</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Уклон</dt>
|
||||
<dd>{formatNumber(frame.surface.slopeDeg)}°</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Шероховатость</dt>
|
||||
<dd>{formatNumber(frame.surface.roughnessM, 3)} м</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Confidence</dt>
|
||||
<dd>{formatNumber(frame.surface.confidence * 100, 0)}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Поверхность</dt>
|
||||
<dd>{frame.counts.surface.toLocaleString("ru-RU")} точек</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Препятствия</dt>
|
||||
<dd>{frame.counts.occupied.toLocaleString("ru-RU")} точек</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="lidar-local-surface__legend">
|
||||
<span><i data-class="surface" />Наблюдаемая поверхность</span>
|
||||
<span><i data-class="occupied" />Выше поверхности</span>
|
||||
<span><i data-class="below" />Нижний выброс</span>
|
||||
<span><i data-class="unknown" />Не классифицировано</span>
|
||||
</div>
|
||||
<p>
|
||||
Пустота между точками остаётся unknown. Этот слой не разрешает
|
||||
движение и не меняет постоянную реконструкцию территории.
|
||||
</p>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
import { LidarLocalSurfacePanel } from "./LidarLocalSurfacePanel";
|
||||
|
||||
function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
@@ -460,6 +461,11 @@ export function LidarQualityWorkspace({
|
||||
<span><i data-color="non-ground" />Оба non-ground</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LidarLocalSurfacePanel
|
||||
selectedWindowKey={selectedFieldWindow?.key ?? null}
|
||||
reloadGeneration={reloadGeneration}
|
||||
/>
|
||||
</>
|
||||
) : groundBenchmark ? (
|
||||
<section
|
||||
|
||||
Reference in New Issue
Block a user