feat: add passive K1 local surface replay

This commit is contained in:
DCCONSTRUCTIONS 2026-07-25 22:47:12 +03:00
parent cfe17e49bb
commit 04a658b218
16 changed files with 2715 additions and 21 deletions

View File

@ -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:

View File

@ -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.",
),
);
}

View File

@ -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;
} }

View File

@ -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;

View File

@ -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(

View File

@ -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>
);
}

View File

@ -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

View File

@ -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,
);
});

View File

@ -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.

View File

@ -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 **Полигон → Датасеты**;

View File

@ -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())

View File

@ -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

View File

@ -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"
), ),

View File

@ -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

View File

@ -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"