feat: add passive K1 local surface replay
This commit is contained in:
parent
cfe17e49bb
commit
04a658b218
|
|
@ -139,6 +139,14 @@ private replay dataset for this work. No K1 firmware change, onboard exporter
|
||||||
or new device command is part of the LiDAR roadmap, and the historical
|
or new device command is part of the LiDAR roadmap, and the historical
|
||||||
`1.27 m` handheld-height experiment is not a runtime constant.
|
`1.27 m` handheld-height experiment is not a runtime constant.
|
||||||
|
|
||||||
|
The first passive replay derivative is now implemented as
|
||||||
|
`missioncore.k1-local-surface/v1`. It binds the immutable `RAVNOVES00` pack,
|
||||||
|
estimates a rolling local surface from map points plus compatible pose, and
|
||||||
|
publishes height, slope, roughness, confidence and conservative observed
|
||||||
|
surface/occupied/unknown evidence. All `526/526` available samples produced a
|
||||||
|
diagnostic result; no free-space, command, navigation or safety authority is
|
||||||
|
inferred. The selected scene is visible in **Парк → Диагностика LiDAR**.
|
||||||
|
|
||||||
The complete RELLIS-3D v1.1 release is now admitted there and its full
|
The complete RELLIS-3D v1.1 release is now admitted there and its full
|
||||||
`2,413`-frame validation split is available in **Полигон → Датасеты**. The
|
`2,413`-frame validation split is available in **Полигон → Датасеты**. The
|
||||||
sealed Current/Patchwork++ comparison rejected Patchwork++ for navigation:
|
sealed Current/Patchwork++ comparison rejected Patchwork++ for navigation:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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 {
|
.lidar-field-cloud {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
|
|
@ -412,6 +420,20 @@
|
||||||
flex-direction: column;
|
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 {
|
.polygon-run-identity dl {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2821,6 +2821,146 @@
|
||||||
margin: 0;
|
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 {
|
.lidar-fallback-review {
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ export type LidarGroundViewMode =
|
||||||
| "disagreement"
|
| "disagreement"
|
||||||
| "candidate-disagreement"
|
| "candidate-disagreement"
|
||||||
| "semantic"
|
| "semantic"
|
||||||
| "ground-truth";
|
| "ground-truth"
|
||||||
|
| "local-surface";
|
||||||
|
|
||||||
export interface LidarGroundPointCloudFrame {
|
export interface LidarGroundPointCloudFrame {
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
|
|
@ -25,6 +26,7 @@ export interface LidarGroundPointCloudFrame {
|
||||||
candidateDisagreement?: number[];
|
candidateDisagreement?: number[];
|
||||||
groundTruthGround?: number[];
|
groundTruthGround?: number[];
|
||||||
evaluationMask?: number[];
|
evaluationMask?: number[];
|
||||||
|
localSurfaceClass?: number[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +69,18 @@ function frameColors(
|
||||||
const current = frame.masks.currentGround[index] === 1;
|
const current = frame.masks.currentGround[index] === 1;
|
||||||
const candidate = frame.masks.candidateGround[index] === 1;
|
const candidate = frame.masks.candidateGround[index] === 1;
|
||||||
const candidateAssigned = frame.masks.candidateAssigned[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 intensity = (frame.intensity0To255?.[index] ?? 96) / 255;
|
||||||
const neutral = 0.16 + intensity * 0.8;
|
const neutral = 0.16 + intensity * 0.8;
|
||||||
setRgb(
|
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,
|
LidarGroundPointCloud,
|
||||||
type LidarGroundViewMode,
|
type LidarGroundViewMode,
|
||||||
} from "./LidarGroundPointCloud";
|
} from "./LidarGroundPointCloud";
|
||||||
|
import { LidarLocalSurfacePanel } from "./LidarLocalSurfacePanel";
|
||||||
|
|
||||||
function formatNumber(value: number | null, digits = 1): string {
|
function formatNumber(value: number | null, digits = 1): string {
|
||||||
if (value === null) return "—";
|
if (value === null) return "—";
|
||||||
|
|
@ -460,6 +461,11 @@ export function LidarQualityWorkspace({
|
||||||
<span><i data-color="non-ground" />Оба non-ground</span>
|
<span><i data-color="non-ground" />Оба non-ground</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LidarLocalSurfacePanel
|
||||||
|
selectedWindowKey={selectedFieldWindow?.key ?? null}
|
||||||
|
reloadGeneration={reloadGeneration}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : groundBenchmark ? (
|
) : groundBenchmark ? (
|
||||||
<section
|
<section
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let parseLidarLocalSurfaceCatalog;
|
||||||
|
let parseLidarLocalSurfaceFrame;
|
||||||
|
let LidarLocalSurfaceContractError;
|
||||||
|
|
||||||
|
const modelId = `k1-local-surface-${"a".repeat(64)}`;
|
||||||
|
const sourcePackId = `e10-lidar-pack-${"b".repeat(64)}`;
|
||||||
|
|
||||||
|
function distribution(value) {
|
||||||
|
return {
|
||||||
|
sample_count: 10,
|
||||||
|
minimum: value,
|
||||||
|
mean: value,
|
||||||
|
p50: value,
|
||||||
|
p95: value,
|
||||||
|
maximum: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function policy(overrides = {}) {
|
||||||
|
return {
|
||||||
|
observed_surface_class: 1,
|
||||||
|
observed_occupied_class: 2,
|
||||||
|
below_surface_or_negative_outlier_class: 3,
|
||||||
|
absence_of_points_means_free: false,
|
||||||
|
unknown_is_traversable: false,
|
||||||
|
persistent_reconstruction_mutated: false,
|
||||||
|
dynamic_object_layer_available: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function model(overrides = {}) {
|
||||||
|
return {
|
||||||
|
model_id: modelId,
|
||||||
|
display_name: "RAVNOVES00 · динамическая локальная поверхность K1",
|
||||||
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
|
source_pack_id: sourcePackId,
|
||||||
|
status: "diagnostic-only",
|
||||||
|
source: {
|
||||||
|
representation: "legacy-e10-vendor-map-with-pose",
|
||||||
|
immutable: true,
|
||||||
|
passive_processing_only: true,
|
||||||
|
firmware_or_device_commands_used: false,
|
||||||
|
frame_count: 12,
|
||||||
|
available_lidar_frames: 10,
|
||||||
|
point_count: 100,
|
||||||
|
timeline_start_seconds: 1,
|
||||||
|
timeline_end_seconds: 2,
|
||||||
|
},
|
||||||
|
surface_model: {},
|
||||||
|
occupancy_policy: policy(),
|
||||||
|
metrics: {
|
||||||
|
frames: {
|
||||||
|
total: 12,
|
||||||
|
source_available: 10,
|
||||||
|
valid: 10,
|
||||||
|
source_unavailable: 2,
|
||||||
|
pose_stale: 0,
|
||||||
|
insufficient_surface: 0,
|
||||||
|
fit_failed: 0,
|
||||||
|
},
|
||||||
|
sensor_height_m: distribution(1.3),
|
||||||
|
slope_deg: distribution(2),
|
||||||
|
roughness_m: distribution(0.04),
|
||||||
|
confidence: distribution(0.8),
|
||||||
|
pose_binding_age_ms: distribution(7),
|
||||||
|
surface_max_age_ms: distribution(1000),
|
||||||
|
build_elapsed_ms: 20,
|
||||||
|
},
|
||||||
|
anchors: [{
|
||||||
|
key: "long-street",
|
||||||
|
label: "Длинная улица",
|
||||||
|
frame_index: 2,
|
||||||
|
source_frame_index: 1350,
|
||||||
|
session_seconds: 1.5,
|
||||||
|
valid: true,
|
||||||
|
}],
|
||||||
|
decision: {
|
||||||
|
status: "replay-experiment-only",
|
||||||
|
production_promotion: false,
|
||||||
|
next_gate: "shadow",
|
||||||
|
},
|
||||||
|
created_at_utc: "2026-07-25T00:00:00Z",
|
||||||
|
ground_truth: false,
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalog(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.k1-local-surface-catalog/v1",
|
||||||
|
configured: true,
|
||||||
|
items: [model()],
|
||||||
|
valid_total: 1,
|
||||||
|
invalid_total: 0,
|
||||||
|
access: "read-only",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.k1-local-surface-frame/v1",
|
||||||
|
model_id: modelId,
|
||||||
|
source_pack_id: sourcePackId,
|
||||||
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
|
frame_index: 2,
|
||||||
|
frame_count: 12,
|
||||||
|
source_frame_index: 1350,
|
||||||
|
session_seconds: 1.5,
|
||||||
|
source_available: true,
|
||||||
|
valid: true,
|
||||||
|
failure_code: 0,
|
||||||
|
point_count: 4,
|
||||||
|
coordinate_frame: "map",
|
||||||
|
distance_unit: "m",
|
||||||
|
points_xyz_m: [[0, 0, 0], [1, 0, 0], [1, 1, 0.7], [2, 0, -0.3]],
|
||||||
|
point_class: [1, 1, 2, 3],
|
||||||
|
point_height_m: [0, 0.02, 0.7, -0.3],
|
||||||
|
pose: {
|
||||||
|
position_xyz_m: [0, 0, 1.3],
|
||||||
|
orientation_xyzw: [0, 0, 0, 1],
|
||||||
|
binding_age_ms: 7,
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
plane_coefficients_map: [0, 0, 1, 0],
|
||||||
|
sensor_height_m: 1.3,
|
||||||
|
slope_deg: 0,
|
||||||
|
roughness_m: 0.02,
|
||||||
|
confidence: 0.8,
|
||||||
|
surface_max_age_ms: 1000,
|
||||||
|
cell_count: 40,
|
||||||
|
inlier_cell_count: 35,
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
classified: 4,
|
||||||
|
surface: 2,
|
||||||
|
occupied: 1,
|
||||||
|
below_surface: 1,
|
||||||
|
},
|
||||||
|
classes: {},
|
||||||
|
occupancy_policy: policy(),
|
||||||
|
ground_truth: false,
|
||||||
|
access: "read-only",
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
parseLidarLocalSurfaceCatalog,
|
||||||
|
parseLidarLocalSurfaceFrame,
|
||||||
|
LidarLocalSurfaceContractError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/lidar/localSurface.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes passive local-surface evidence", () => {
|
||||||
|
const decoded = parseLidarLocalSurfaceCatalog(catalog());
|
||||||
|
assert.equal(decoded.items[0].source.passiveProcessingOnly, true);
|
||||||
|
assert.equal(decoded.items[0].metrics.sensorHeightM.p50, 1.3);
|
||||||
|
assert.equal(decoded.items[0].anchors[0].sourceFrameIndex, 1350);
|
||||||
|
|
||||||
|
const decodedFrame = parseLidarLocalSurfaceFrame(frame());
|
||||||
|
assert.equal(decodedFrame.counts.occupied, 1);
|
||||||
|
assert.equal(decodedFrame.surface.sensorHeightM, 1.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects inferred free space", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseLidarLocalSurfaceCatalog(catalog({
|
||||||
|
items: [model({
|
||||||
|
occupancy_policy: policy({ absence_of_points_means_free: true }),
|
||||||
|
})],
|
||||||
|
})),
|
||||||
|
LidarLocalSurfaceContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects command authority", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseLidarLocalSurfaceFrame(frame({
|
||||||
|
authority: {
|
||||||
|
commands_enabled: true,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
LidarLocalSurfaceContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
Date: 2026-07-25
|
Date: 2026-07-25
|
||||||
Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B
|
Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B
|
||||||
complete; full GOOSE and RELLIS qualification complete; K1 local-world-model
|
complete; full GOOSE and RELLIS qualification complete; L2.6a K1 replay
|
||||||
gate next
|
local-surface slice implemented; operator review and live shadow next
|
||||||
Scope: passively received real-time K1 point/pose evidence, immutable replay and
|
Scope: passively received real-time K1 point/pose evidence, immutable replay and
|
||||||
future live shadow processing
|
future live shadow processing
|
||||||
Explicitly out of scope: K1 firmware modification, a new onboard exporter, new
|
Explicitly out of scope: K1 firmware modification, a new onboard exporter, new
|
||||||
|
|
@ -384,28 +384,51 @@ showed that Patchwork++ slightly improved Ground IoU while reducing obstacle
|
||||||
non-ground recall from `80.46%` to `69.70%`; the candidate was rejected.
|
non-ground recall from `80.46%` to `69.70%`; the candidate was rejected.
|
||||||
Dataset expansion is no longer the next gate.
|
Dataset expansion is no longer the next gate.
|
||||||
|
|
||||||
### L2.6 — K1 local world model — next
|
### L2.6 — K1 local world model — in progress
|
||||||
|
|
||||||
- [ ] Bind the immutable `RAVNOVES00` source and replay-pack-v2 evidence without
|
- [x] Bind the immutable `RAVNOVES00` E10 source by pack identity and artifact
|
||||||
rewriting either generation.
|
hash without copying or rewriting the source generation.
|
||||||
- [ ] Transform each admitted map increment through the nearest compatible
|
- [ ] Mirror the same accepted profile over replay-pack-v2 evidence while
|
||||||
`T_map_from_lidar` and publish pose-binding age explicitly.
|
preserving its separate identity and field-retention contract.
|
||||||
- [ ] Estimate a time-varying local surface for ground-vehicle profiles using
|
- [x] Reject stale pose binding and publish pose-binding age explicitly; keep
|
||||||
|
map-native processing honest instead of claiming that pose inversion
|
||||||
|
recreates an original sensor sweep.
|
||||||
|
- [x] Estimate a time-varying local surface for ground-vehicle profiles using
|
||||||
robust spatial cells and temporal support; do not use the historical
|
robust spatial cells and temporal support; do not use the historical
|
||||||
`1.27 m` value.
|
`1.27 m` value.
|
||||||
- [ ] Publish surface height, slope, roughness, step/curb candidates and
|
- [x] Publish surface-relative height, slope, roughness and confidence
|
||||||
confidence separately from semantic classes.
|
separately from semantic classes.
|
||||||
- [ ] Produce short-TTL `occupied` and `unknown` layers from current evidence.
|
- [ ] Add independently reviewable step/curb candidates; do not derive them
|
||||||
|
from a single global height threshold.
|
||||||
|
- [x] Produce short-TTL observed-surface, `occupied` and `unknown` evidence.
|
||||||
Do not infer `free` merely because a mapped point is absent.
|
Do not infer `free` merely because a mapped point is absent.
|
||||||
- [ ] Keep persistent reconstruction, recent collision evidence and dynamic
|
- [x] Leave the immutable persistent reconstruction untouched by the local
|
||||||
observations as separate derivatives of the same source.
|
derivative.
|
||||||
|
- [ ] Add recent-collision and dynamic-observation layers as separate
|
||||||
|
derivatives with independent decay and provenance.
|
||||||
- [ ] Reuse the accepted camera-to-LiDAR projection as an optional semantic
|
- [ ] Reuse the accepted camera-to-LiDAR projection as an optional semantic
|
||||||
layer with source, confidence, freshness and conflict fields.
|
layer with source, confidence, freshness and conflict fields.
|
||||||
- [ ] Measure latency, point age, pose-binding age, temporal stability, obstacle
|
- [ ] Complete the qualification report with per-frame latency, point age,
|
||||||
preservation and memory growth over the complete recording.
|
temporal stability, obstacle preservation and memory growth.
|
||||||
- [ ] Replay the same profiles through a bounded latest-wins live-shadow queue;
|
- [ ] Replay the same profiles through a bounded latest-wins live-shadow queue;
|
||||||
no K1 command, navigation or safety authority is added.
|
no K1 command, navigation or safety authority is added.
|
||||||
|
|
||||||
|
The implemented `missioncore.k1-local-surface/v1` derivative is reproducible
|
||||||
|
through `experiments/perception/run_k1_local_surface.py` and is exposed
|
||||||
|
read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame
|
||||||
|
endpoint. **Парк → Диагностика LiDAR** reuses the five RAVNOVES00 scene
|
||||||
|
selectors and shows the selected source frame as observed surface, observed
|
||||||
|
occupied-above-surface, negative outlier and unclassified evidence. The React
|
||||||
|
contract is provider-neutral; K1 remains a bound backend source rather than UI
|
||||||
|
implementation knowledge.
|
||||||
|
|
||||||
|
The complete RAVNOVES00 run covered all `526/526` available LiDAR samples.
|
||||||
|
There were zero stale-pose, insufficient-surface or failed-fit frames. Observed
|
||||||
|
diagnostic distributions are: derived sensor-to-surface height `1.295 m` p50,
|
||||||
|
roughness `0.054 m` p95, slope `3.172°` p95 and pose-binding age `19.113 ms`
|
||||||
|
p95. These values describe this recording only; they are not calibration,
|
||||||
|
ground truth or a navigation gate. Free space remains unavailable.
|
||||||
|
|
||||||
Exit: one immutable K1 session yields both a persistent reconstruction and a
|
Exit: one immutable K1 session yields both a persistent reconstruction and a
|
||||||
bounded local world state without hard-coded terrain height or scanner-side
|
bounded local world state without hard-coded terrain height or scanner-side
|
||||||
changes.
|
changes.
|
||||||
|
|
@ -487,7 +510,10 @@ natural-ground recall from `51.76%` to `76.22%` and stays below `23.41 ms` p95
|
||||||
across 961 frames. Full RELLIS qualification covers `2,413` validation frames
|
across 961 frames. Full RELLIS qualification covers `2,413` validation frames
|
||||||
and rejects Patchwork++ because the small Ground-IoU gain came with unacceptable
|
and rejects Patchwork++ because the small Ground-IoU gain came with unacceptable
|
||||||
obstacle loss. Public-dataset ground qualification is therefore complete
|
obstacle loss. Public-dataset ground qualification is therefore complete
|
||||||
enough for the current decision. The highest-value immediate work is the K1
|
enough for the current decision. The first K1 local-surface replay slice now
|
||||||
local world model over `RAVNOVES00`, followed by bounded live shadow. Nvblox,
|
covers all available `RAVNOVES00` samples and is visible in the operator
|
||||||
raw-scan detectors and alternative SLAM remain optional later gates because the
|
interface. The highest-value immediate work is operator review, explicit
|
||||||
current report contract does not carry their required ray/timing semantics.
|
step/curb and temporal-stability qualification, followed by bounded live
|
||||||
|
shadow. Nvblox, raw-scan detectors and alternative SLAM remain optional later
|
||||||
|
gates because the current report contract does not carry their required
|
||||||
|
ray/timing semantics.
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ Implemented now:
|
||||||
- count, finite-value and maximum-point safety gates;
|
- count, finite-value and maximum-point safety gates;
|
||||||
- immutable point-aligned arrays;
|
- immutable point-aligned arrays;
|
||||||
- a fail-closed K1 `lio_pcl` boundary;
|
- a fail-closed K1 `lio_pcl` boundary;
|
||||||
|
- a content-addressed `missioncore.k1-local-surface/v1` replay derivative over
|
||||||
|
immutable `RAVNOVES00`, with dynamic height/slope/roughness/confidence,
|
||||||
|
explicit pose-binding age and conservative observed occupied/unknown policy;
|
||||||
|
- a provider-neutral read-only local-surface view in
|
||||||
|
**Парк → Диагностика LiDAR**, synchronized to the five existing
|
||||||
|
RAVNOVES00 scene selectors;
|
||||||
- worker storage admission for `D:\NDC_MISSIONCORE\datasets` and
|
- worker storage admission for `D:\NDC_MISSIONCORE\datasets` and
|
||||||
`/mnt/d/NDC_MISSIONCORE/datasets`;
|
`/mnt/d/NDC_MISSIONCORE/datasets`;
|
||||||
- a dedicated, honest dataset catalog in **Полигон → Датасеты**;
|
- a dedicated, honest dataset catalog in **Полигон → Датасеты**;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.compute import (
|
||||||
|
E10LidarFieldSource,
|
||||||
|
K1LocalSurfaceProfile,
|
||||||
|
K1LocalSurfaceV1,
|
||||||
|
build_k1_local_surface,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Build a passive, source-bound K1 rolling local-surface derivative "
|
||||||
|
"without firmware commands or a hardcoded sensor height."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("source_pack", type=Path)
|
||||||
|
parser.add_argument("output_root", type=Path)
|
||||||
|
parser.add_argument("--local-radius-m", type=float, default=10.0)
|
||||||
|
parser.add_argument("--cell-size-m", type=float, default=0.45)
|
||||||
|
parser.add_argument("--surface-ttl-s", type=float, default=1.25)
|
||||||
|
parser.add_argument("--minimum-surface-cells", type=int, default=18)
|
||||||
|
parser.add_argument("--surface-band-m", type=float, default=0.16)
|
||||||
|
parser.add_argument("--obstacle-min-height-m", type=float, default=0.20)
|
||||||
|
parser.add_argument("--obstacle-max-height-m", type=float, default=3.5)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arguments = _arguments()
|
||||||
|
profile = K1LocalSurfaceProfile(
|
||||||
|
local_radius_m=arguments.local_radius_m,
|
||||||
|
cell_size_m=arguments.cell_size_m,
|
||||||
|
surface_ttl_s=arguments.surface_ttl_s,
|
||||||
|
minimum_surface_cells=arguments.minimum_surface_cells,
|
||||||
|
surface_band_m=arguments.surface_band_m,
|
||||||
|
obstacle_min_height_m=arguments.obstacle_min_height_m,
|
||||||
|
obstacle_max_height_m=arguments.obstacle_max_height_m,
|
||||||
|
)
|
||||||
|
source = E10LidarFieldSource(arguments.source_pack)
|
||||||
|
try:
|
||||||
|
output = build_k1_local_surface(
|
||||||
|
source,
|
||||||
|
arguments.output_root,
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
model = K1LocalSurfaceV1(output)
|
||||||
|
try:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"model_id": model.model_id,
|
||||||
|
"display_name": model.report["display_name"],
|
||||||
|
"source": model.report["source"],
|
||||||
|
"surface_model": model.report["surface_model"],
|
||||||
|
"occupancy_policy": model.report["occupancy_policy"],
|
||||||
|
"metrics": model.report["metrics"],
|
||||||
|
"anchors": model.report["anchors"],
|
||||||
|
"decision": model.report["decision"],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
model.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -106,6 +106,16 @@ from .lidar_ground import (
|
||||||
lidar_ground_frame_detail,
|
lidar_ground_frame_detail,
|
||||||
score_ground_labels,
|
score_ground_labels,
|
||||||
)
|
)
|
||||||
|
from .lidar_local_surface import (
|
||||||
|
DEFAULT_K1_LOCAL_SURFACE_PROFILE,
|
||||||
|
K1_LOCAL_SURFACE_FRAME_SCHEMA,
|
||||||
|
K1_LOCAL_SURFACE_REPORT_SCHEMA,
|
||||||
|
K1_LOCAL_SURFACE_SCHEMA,
|
||||||
|
K1LocalSurfaceProfile,
|
||||||
|
K1LocalSurfaceV1,
|
||||||
|
build_k1_local_surface,
|
||||||
|
k1_local_surface_catalog_item,
|
||||||
|
)
|
||||||
from .lidar_replay import (
|
from .lidar_replay import (
|
||||||
LIDAR_EQUIVALENCE_REPORT_SCHEMA,
|
LIDAR_EQUIVALENCE_REPORT_SCHEMA,
|
||||||
LIDAR_QUALITY_REPORT_SCHEMA,
|
LIDAR_QUALITY_REPORT_SCHEMA,
|
||||||
|
|
@ -185,6 +195,7 @@ __all__ = [
|
||||||
"AnnotationWorkspaceError",
|
"AnnotationWorkspaceError",
|
||||||
"COMPUTE_JOB_SCHEMA",
|
"COMPUTE_JOB_SCHEMA",
|
||||||
"DEFAULT_QUALIFICATION_FRAME_COUNT",
|
"DEFAULT_QUALIFICATION_FRAME_COUNT",
|
||||||
|
"DEFAULT_K1_LOCAL_SURFACE_PROFILE",
|
||||||
"EVALUATION_PACK_SCHEMA",
|
"EVALUATION_PACK_SCHEMA",
|
||||||
"EvaluationFrameRequest",
|
"EvaluationFrameRequest",
|
||||||
"EvaluationPackFrame",
|
"EvaluationPackFrame",
|
||||||
|
|
@ -195,6 +206,9 @@ __all__ = [
|
||||||
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
"LIDAR_GROUND_BENCHMARK_REPORT_SCHEMA",
|
||||||
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
"LIDAR_GROUND_BENCHMARK_SCHEMA",
|
||||||
"LIDAR_GROUND_FRAME_SCHEMA",
|
"LIDAR_GROUND_FRAME_SCHEMA",
|
||||||
|
"K1_LOCAL_SURFACE_FRAME_SCHEMA",
|
||||||
|
"K1_LOCAL_SURFACE_REPORT_SCHEMA",
|
||||||
|
"K1_LOCAL_SURFACE_SCHEMA",
|
||||||
"LIDAR_FIELD_REVIEW_REPORT_SCHEMA",
|
"LIDAR_FIELD_REVIEW_REPORT_SCHEMA",
|
||||||
"LIDAR_FIELD_REVIEW_SCHEMA",
|
"LIDAR_FIELD_REVIEW_SCHEMA",
|
||||||
"LIDAR_FIELD_REVIEW_WINDOW_SCHEMA",
|
"LIDAR_FIELD_REVIEW_WINDOW_SCHEMA",
|
||||||
|
|
@ -217,6 +231,8 @@ __all__ = [
|
||||||
"LidarReadiness",
|
"LidarReadiness",
|
||||||
"LidarGroundBenchmarkV1",
|
"LidarGroundBenchmarkV1",
|
||||||
"LidarGroundError",
|
"LidarGroundError",
|
||||||
|
"K1LocalSurfaceProfile",
|
||||||
|
"K1LocalSurfaceV1",
|
||||||
"LidarReplayError",
|
"LidarReplayError",
|
||||||
"LidarReplayPackV2",
|
"LidarReplayPackV2",
|
||||||
"LidarReplayPointFrame",
|
"LidarReplayPointFrame",
|
||||||
|
|
@ -280,7 +296,9 @@ __all__ = [
|
||||||
"build_lidar_replay_pack_v2",
|
"build_lidar_replay_pack_v2",
|
||||||
"build_lidar_ground_annotation_template",
|
"build_lidar_ground_annotation_template",
|
||||||
"build_lidar_ground_benchmark",
|
"build_lidar_ground_benchmark",
|
||||||
|
"build_k1_local_surface",
|
||||||
"lidar_ground_frame_detail",
|
"lidar_ground_frame_detail",
|
||||||
|
"k1_local_surface_catalog_item",
|
||||||
"E10_LIDAR_PACK_SCHEMA",
|
"E10_LIDAR_PACK_SCHEMA",
|
||||||
"FIELD_REVIEW_ARRAYS_NAME",
|
"FIELD_REVIEW_ARRAYS_NAME",
|
||||||
"FIELD_REVIEW_MANIFEST_NAME",
|
"FIELD_REVIEW_MANIFEST_NAME",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -420,6 +420,27 @@ app.include_router(
|
||||||
/ "lidar-ground-v1"
|
/ "lidar-ground-v1"
|
||||||
/ "benchmarks"
|
/ "benchmarks"
|
||||||
),
|
),
|
||||||
|
field_review_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "lidar-field-review-v1"
|
||||||
|
/ "reviews"
|
||||||
|
),
|
||||||
|
local_surface_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "k1-local-surface-v1"
|
||||||
|
/ "models"
|
||||||
|
),
|
||||||
|
e10_source_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "e10"
|
||||||
|
/ "lidar-packs"
|
||||||
|
),
|
||||||
dataset_admission_provider=lambda: (
|
dataset_admission_provider=lambda: (
|
||||||
REPOSITORY_ROOT / ".runtime" / "dataset-gateway" / "admission.json"
|
REPOSITORY_ROOT / ".runtime" / "dataset-gateway" / "admission.json"
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ from typing import Annotated, Any, Final
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
|
|
||||||
from k1link.compute import (
|
from k1link.compute import (
|
||||||
|
E10LidarFieldSource,
|
||||||
|
K1LocalSurfaceV1,
|
||||||
LidarFieldReviewV1,
|
LidarFieldReviewV1,
|
||||||
LidarGroundBenchmarkV1,
|
LidarGroundBenchmarkV1,
|
||||||
LidarGroundError,
|
LidarGroundError,
|
||||||
LidarReplayError,
|
LidarReplayError,
|
||||||
LidarReplayPackV2,
|
LidarReplayPackV2,
|
||||||
|
k1_local_surface_catalog_item,
|
||||||
lidar_field_review_catalog_item,
|
lidar_field_review_catalog_item,
|
||||||
lidar_ground_benchmark_catalog_item,
|
lidar_ground_benchmark_catalog_item,
|
||||||
lidar_ground_frame_detail,
|
lidar_ground_frame_detail,
|
||||||
|
|
@ -34,9 +37,12 @@ from k1link.datasets import (
|
||||||
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
|
LIDAR_CATALOG_SCHEMA: Final = "missioncore.lidar-replay-pack-catalog/v1"
|
||||||
LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1"
|
LIDAR_GROUND_CATALOG_SCHEMA: Final = "missioncore.lidar-ground-benchmark-catalog/v1"
|
||||||
LIDAR_FIELD_REVIEW_CATALOG_SCHEMA: Final = "missioncore.lidar-field-review-catalog/v1"
|
LIDAR_FIELD_REVIEW_CATALOG_SCHEMA: Final = "missioncore.lidar-field-review-catalog/v1"
|
||||||
|
K1_LOCAL_SURFACE_CATALOG_SCHEMA: Final = "missioncore.k1-local-surface-catalog/v1"
|
||||||
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
|
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
|
||||||
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
_BENCHMARK_ID = re.compile(r"^ground-benchmark-[a-f0-9]{64}$")
|
||||||
_FIELD_REVIEW_ID = re.compile(r"^lidar-field-review-[a-f0-9]{64}$")
|
_FIELD_REVIEW_ID = re.compile(r"^lidar-field-review-[a-f0-9]{64}$")
|
||||||
|
_LOCAL_SURFACE_ID = re.compile(r"^k1-local-surface-[a-f0-9]{64}$")
|
||||||
|
_E10_PACK_ID = re.compile(r"^e10-lidar-pack-[a-f0-9]{64}$")
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
DatasetArtifactProvider = Callable[[], Path | None]
|
DatasetArtifactProvider = Callable[[], Path | None]
|
||||||
|
|
||||||
|
|
@ -56,11 +62,23 @@ def configured_lidar_field_review_root() -> Path | None:
|
||||||
return Path(value).expanduser().absolute() if value else None
|
return Path(value).expanduser().absolute() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def configured_k1_local_surface_root() -> Path | None:
|
||||||
|
value = os.environ.get("MISSIONCORE_K1_LOCAL_SURFACE_ROOT", "").strip()
|
||||||
|
return Path(value).expanduser().absolute() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def configured_e10_lidar_source_root() -> Path | None:
|
||||||
|
value = os.environ.get("MISSIONCORE_E10_LIDAR_SOURCE_ROOT", "").strip()
|
||||||
|
return Path(value).expanduser().absolute() if value else None
|
||||||
|
|
||||||
|
|
||||||
def build_lidar_router(
|
def build_lidar_router(
|
||||||
*,
|
*,
|
||||||
root_provider: RootProvider = configured_lidar_replay_root,
|
root_provider: RootProvider = configured_lidar_replay_root,
|
||||||
ground_root_provider: RootProvider = configured_lidar_ground_root,
|
ground_root_provider: RootProvider = configured_lidar_ground_root,
|
||||||
field_review_root_provider: RootProvider = configured_lidar_field_review_root,
|
field_review_root_provider: RootProvider = configured_lidar_field_review_root,
|
||||||
|
local_surface_root_provider: RootProvider = configured_k1_local_surface_root,
|
||||||
|
e10_source_root_provider: RootProvider = configured_e10_lidar_source_root,
|
||||||
dataset_admission_provider: DatasetArtifactProvider = configured_dataset_admission_manifest,
|
dataset_admission_provider: DatasetArtifactProvider = configured_dataset_admission_manifest,
|
||||||
dataset_preview_provider: DatasetArtifactProvider = configured_dataset_preview,
|
dataset_preview_provider: DatasetArtifactProvider = configured_dataset_preview,
|
||||||
dataset_rellis_preview_provider: DatasetArtifactProvider = lambda: None,
|
dataset_rellis_preview_provider: DatasetArtifactProvider = lambda: None,
|
||||||
|
|
@ -483,4 +501,111 @@ def build_lidar_router(
|
||||||
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
headers={"Cache-Control": "private, max-age=31536000, immutable"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/local-surfaces")
|
||||||
|
def list_k1_local_surfaces(
|
||||||
|
limit: int = Query(default=10, ge=1, le=50),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
root = local_surface_root_provider()
|
||||||
|
if root is None or not root.is_dir():
|
||||||
|
return {
|
||||||
|
"schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA,
|
||||||
|
"configured": root is not None,
|
||||||
|
"items": [],
|
||||||
|
"valid_total": 0,
|
||||||
|
"invalid_total": 0,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
invalid_total = 0
|
||||||
|
candidates = sorted(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in root.iterdir()
|
||||||
|
if candidate.is_dir()
|
||||||
|
and _LOCAL_SURFACE_ID.fullmatch(candidate.name) is not None
|
||||||
|
),
|
||||||
|
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
model = K1LocalSurfaceV1(candidate)
|
||||||
|
try:
|
||||||
|
items.append(k1_local_surface_catalog_item(model))
|
||||||
|
finally:
|
||||||
|
model.close()
|
||||||
|
except (LidarGroundError, OSError):
|
||||||
|
invalid_total += 1
|
||||||
|
return {
|
||||||
|
"schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA,
|
||||||
|
"configured": True,
|
||||||
|
"items": items[:limit],
|
||||||
|
"valid_total": len(items),
|
||||||
|
"invalid_total": invalid_total,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/local-surfaces/{model_id}/frames/{frame_index}")
|
||||||
|
def get_k1_local_surface_frame(
|
||||||
|
model_id: str,
|
||||||
|
frame_index: int,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if _LOCAL_SURFACE_ID.fullmatch(model_id) is None or frame_index < 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="K1 local-surface frame не найден",
|
||||||
|
)
|
||||||
|
model_root = local_surface_root_provider()
|
||||||
|
source_root = e10_source_root_provider()
|
||||||
|
if model_root is None or not model_root.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="K1 local-surface storage не настроен",
|
||||||
|
)
|
||||||
|
if source_root is None or not source_root.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="E10 LiDAR source storage не настроен",
|
||||||
|
)
|
||||||
|
model_path = model_root / model_id
|
||||||
|
if not model_path.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="K1 local-surface model не найден",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model = K1LocalSurfaceV1(model_path)
|
||||||
|
try:
|
||||||
|
source_pack_id = model.identity.get("source_pack_id")
|
||||||
|
if (
|
||||||
|
not isinstance(source_pack_id, str)
|
||||||
|
or _E10_PACK_ID.fullmatch(source_pack_id) is None
|
||||||
|
):
|
||||||
|
raise LidarGroundError("K1 local-surface source id is invalid")
|
||||||
|
source_path = source_root / source_pack_id
|
||||||
|
if not source_path.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Связанный E10 LiDAR source не найден",
|
||||||
|
)
|
||||||
|
source = E10LidarFieldSource(source_path)
|
||||||
|
try:
|
||||||
|
return model.frame_detail(source, frame_index)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
finally:
|
||||||
|
model.close()
|
||||||
|
except IndexError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="K1 local-surface frame не найден",
|
||||||
|
) from exc
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (LidarGroundError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="K1 local-surface evidence не прошло проверку целостности",
|
||||||
|
) from exc
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from k1link.compute import (
|
||||||
|
E10_LIDAR_PACK_SCHEMA,
|
||||||
|
E10LidarFieldSource,
|
||||||
|
K1LocalSurfaceProfile,
|
||||||
|
K1LocalSurfaceV1,
|
||||||
|
build_k1_local_surface,
|
||||||
|
)
|
||||||
|
from k1link.web.lidar_api import build_lidar_router
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _source_pack(root: Path) -> Path:
|
||||||
|
frame_count = 8
|
||||||
|
available = np.asarray([True, True, True, True, True, True, True, False])
|
||||||
|
poses: list[list[float]] = []
|
||||||
|
clouds: list[np.ndarray] = []
|
||||||
|
offsets = [0]
|
||||||
|
for frame_index in range(frame_count):
|
||||||
|
pose_x = frame_index * 0.35
|
||||||
|
pose_y = 0.1 * np.sin(frame_index)
|
||||||
|
ground_z = 0.04 * pose_x - 0.015 * pose_y
|
||||||
|
poses.append([pose_x, pose_y, ground_z + 1.42])
|
||||||
|
if not available[frame_index]:
|
||||||
|
offsets.append(offsets[-1])
|
||||||
|
continue
|
||||||
|
axis = np.linspace(-3.5, 3.5, 12)
|
||||||
|
xx, yy = np.meshgrid(axis + pose_x, axis + pose_y)
|
||||||
|
zz = 0.04 * xx - 0.015 * yy + 0.008 * np.sin(xx * 2 + frame_index)
|
||||||
|
ground = np.column_stack((xx.ravel(), yy.ravel(), zz.ravel()))
|
||||||
|
obstacle_xy = ground[::13, :2]
|
||||||
|
obstacle_z = (
|
||||||
|
0.04 * obstacle_xy[:, 0] - 0.015 * obstacle_xy[:, 1] + 0.75
|
||||||
|
)
|
||||||
|
obstacle = np.column_stack((obstacle_xy, obstacle_z))
|
||||||
|
cloud = np.concatenate((ground, obstacle)).astype("<f4")
|
||||||
|
clouds.append(cloud)
|
||||||
|
offsets.append(offsets[-1] + cloud.shape[0])
|
||||||
|
points = np.concatenate(clouds).astype("<f4")
|
||||||
|
arrays = {
|
||||||
|
"frame_indices": np.arange(frame_count, dtype="<i8"),
|
||||||
|
"source_frame_indices": np.arange(100, 100 + frame_count * 10, 10, dtype="<i8"),
|
||||||
|
"session_seconds": np.arange(frame_count, dtype="<f8") * 0.1 + 10.0,
|
||||||
|
"sample_available": available.astype("?"),
|
||||||
|
"cloud_offsets": np.asarray(offsets, dtype="<i8"),
|
||||||
|
"cloud_points_map": points,
|
||||||
|
"pose_positions_map": np.asarray(poses, dtype="<f8"),
|
||||||
|
"pose_quaternions_map_from_lidar": np.tile(
|
||||||
|
np.asarray([0.0, 0.0, 0.0, 1.0], dtype="<f8"),
|
||||||
|
(frame_count, 1),
|
||||||
|
),
|
||||||
|
"lidar_camera_delta_ms": np.zeros(frame_count, dtype="<f8"),
|
||||||
|
"pose_point_delta_ms": np.asarray(
|
||||||
|
[4.0, 5.0, 6.0, 7.0, 130.0, 5.0, 6.0, 0.0],
|
||||||
|
dtype="<f8",
|
||||||
|
),
|
||||||
|
"intrinsic_fx_fy_cx_cy": np.asarray(
|
||||||
|
[100.0, 100.0, 50.0, 50.0],
|
||||||
|
dtype="<f8",
|
||||||
|
),
|
||||||
|
"distortion_kb4": np.zeros(4, dtype="<f8"),
|
||||||
|
"t_camera_from_lidar": np.eye(4, dtype="<f8"),
|
||||||
|
}
|
||||||
|
identity = {
|
||||||
|
"schema_version": E10_LIDAR_PACK_SCHEMA,
|
||||||
|
"session_id": "synthetic-ravnoves00-local-surface",
|
||||||
|
"frame_count": frame_count,
|
||||||
|
"available_lidar_frames": int(np.count_nonzero(available)),
|
||||||
|
"point_count": int(points.shape[0]),
|
||||||
|
"timeline_start_seconds": 10.0,
|
||||||
|
"timeline_end_seconds": 10.7,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
pack = root / f"e10-lidar-pack-{identity_sha256}"
|
||||||
|
pack.mkdir(parents=True)
|
||||||
|
arrays_path = pack / "lidar-pack.npz"
|
||||||
|
np.savez_compressed(arrays_path, **arrays) # type: ignore[arg-type]
|
||||||
|
manifest = {
|
||||||
|
"schema_version": E10_LIDAR_PACK_SCHEMA,
|
||||||
|
"pack_id": pack.name,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"artifact": {
|
||||||
|
"path": arrays_path.name,
|
||||||
|
"media_type": "application/x-npz",
|
||||||
|
"byte_length": arrays_path.stat().st_size,
|
||||||
|
"sha256": _sha256(arrays_path),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
(pack / "manifest.json").write_bytes(_canonical_json(manifest))
|
||||||
|
return pack
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(router: APIRouter, path: str) -> object:
|
||||||
|
for route in router.routes:
|
||||||
|
if (
|
||||||
|
isinstance(route, APIRoute)
|
||||||
|
and route.path == path
|
||||||
|
and route.methods is not None
|
||||||
|
and "GET" in route.methods
|
||||||
|
):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"GET {path} route is missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_k1_local_surface_is_dynamic_source_bound_and_read_only(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
source_path = _source_pack(tmp_path / "source")
|
||||||
|
source_artifact = source_path / "lidar-pack.npz"
|
||||||
|
source_sha256 = _sha256(source_artifact)
|
||||||
|
profile = K1LocalSurfaceProfile(
|
||||||
|
profile_id="synthetic-dynamic-local-surface/v1",
|
||||||
|
local_radius_m=5.0,
|
||||||
|
cell_size_m=0.5,
|
||||||
|
surface_ttl_s=0.5,
|
||||||
|
minimum_surface_cells=12,
|
||||||
|
)
|
||||||
|
source = E10LidarFieldSource(source_path)
|
||||||
|
try:
|
||||||
|
output = build_k1_local_surface(
|
||||||
|
source,
|
||||||
|
tmp_path / "models",
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
duplicate = build_k1_local_surface(
|
||||||
|
source,
|
||||||
|
tmp_path / "models",
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
assert duplicate == output
|
||||||
|
assert _sha256(source_artifact) == source_sha256
|
||||||
|
|
||||||
|
model = K1LocalSurfaceV1(output)
|
||||||
|
source = E10LidarFieldSource(source_path)
|
||||||
|
try:
|
||||||
|
detail = model.frame_detail(source, 2)
|
||||||
|
assert model.report["source"]["passive_processing_only"] is True
|
||||||
|
assert model.report["source"]["firmware_or_device_commands_used"] is False
|
||||||
|
assert model.report["surface_model"]["hardcoded_height_m"] is None
|
||||||
|
assert model.report["occupancy_policy"]["absence_of_points_means_free"] is False
|
||||||
|
assert model.report["metrics"]["frames"]["valid"] >= 5
|
||||||
|
assert detail["valid"] is True
|
||||||
|
assert 1.2 < detail["surface"]["sensor_height_m"] < 1.6
|
||||||
|
assert detail["counts"]["surface"] > 50
|
||||||
|
assert detail["counts"]["occupied"] > 0
|
||||||
|
assert detail["authority"]["commands_enabled"] is False
|
||||||
|
assert "1.27" not in repr({"identity": model.identity, "report": model.report})
|
||||||
|
assert str(tmp_path) not in repr(detail)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
model.close()
|
||||||
|
|
||||||
|
router = build_lidar_router(
|
||||||
|
root_provider=lambda: None,
|
||||||
|
ground_root_provider=lambda: None,
|
||||||
|
field_review_root_provider=lambda: None,
|
||||||
|
local_surface_root_provider=lambda: output.parent,
|
||||||
|
e10_source_root_provider=lambda: source_path.parent,
|
||||||
|
)
|
||||||
|
catalog_route = _endpoint(router, "/api/v1/lidar/local-surfaces")
|
||||||
|
frame_route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/lidar/local-surfaces/{model_id}/frames/{frame_index}",
|
||||||
|
)
|
||||||
|
catalog = catalog_route(limit=10) # type: ignore[operator]
|
||||||
|
frame = frame_route(model_id=output.name, frame_index=2) # type: ignore[operator]
|
||||||
|
assert catalog["valid_total"] == 1
|
||||||
|
assert catalog["items"][0]["status"] == "diagnostic-only"
|
||||||
|
assert frame["model_id"] == output.name
|
||||||
|
assert frame["access"] == "read-only"
|
||||||
Loading…
Reference in New Issue