feat: qualify K1 local surface over time

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 23:22:55 +03:00
parent 04a658b218
commit 3449b2bc3e
13 changed files with 1528 additions and 49 deletions
@@ -48,6 +48,28 @@ export interface LidarLocalSurfaceModel {
confidence: LidarLocalSurfaceDistribution;
poseBindingAgeMs: LidarLocalSurfaceDistribution;
surfaceMaxAgeMs: LidarLocalSurfaceDistribution;
temporalQualification: {
prediction: {
currentFrameExcluded: true;
sampleCount: number;
residualP50M: LidarLocalSurfaceDistribution;
residualP95M: LidarLocalSurfaceDistribution;
inlierFraction: LidarLocalSurfaceDistribution;
};
stability: {
sampleCount: number;
heightDeltaM: LidarLocalSurfaceDistribution;
slopeDeltaDeg: LidarLocalSurfaceDistribution;
roughnessDeltaM: LidarLocalSurfaceDistribution;
jumpCount: number;
};
stepCandidates: {
isGroundTruth: false;
framesWithCandidates: number;
cellCount: LidarLocalSurfaceDistribution;
pointCount: LidarLocalSurfaceDistribution;
};
} | null;
};
anchors: LidarLocalSurfaceAnchor[];
occupancyPolicy: {
@@ -88,6 +110,7 @@ export interface LidarLocalSurfaceFrame {
pointsXyzM: Array<[number, number, number]>;
pointClass: number[];
pointHeightM: number[];
pointStepCandidate: number[];
pose: {
positionXyzM: [number, number, number];
orientationXyzw: [number, number, number, number];
@@ -108,12 +131,56 @@ export interface LidarLocalSurfaceFrame {
surface: number;
occupied: number;
belowSurface: number;
stepCandidate: number;
};
prediction: {
available: boolean;
currentFrameExcluded: true;
cellCount: number;
residualP50M: number;
residualP95M: number;
inlierFraction: number;
};
temporal: {
compared: boolean;
heightDeltaM: number;
slopeDeltaDeg: number;
roughnessDeltaM: number;
jump: boolean;
stepCandidateCellCount: number;
};
occupancyPolicy: LidarLocalSurfaceModel["occupancyPolicy"];
groundTruth: false;
authority: LidarLocalSurfaceModel["authority"];
}
export interface LidarLocalSurfaceTimeline {
modelId: string;
sourcePackId: string;
sessionId: string;
frameCount: number;
sourceFrameIndex: number[];
sessionSeconds: number[];
sourceAvailable: number[];
valid: number[];
predictionAvailable: number[];
predictionResidualP50M: number[];
predictionResidualP95M: number[];
predictionInlierFraction: number[];
sensorHeightM: number[];
slopeDeg: number[];
roughnessM: number[];
confidence: number[];
temporalCompared: number[];
heightDeltaM: number[];
slopeDeltaDeg: number[];
roughnessDeltaM: number[];
temporalJump: number[];
stepCandidatePointCount: number[];
groundTruth: false;
authority: LidarLocalSurfaceModel["authority"];
}
export class LidarLocalSurfaceContractError extends Error {}
export class LidarLocalSurfaceApiError extends Error {
@@ -199,6 +266,39 @@ function tuple(
return values;
}
function finiteVector(
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 integerVector(
value: unknown,
length: number,
label: string,
maximum?: number,
): number[] {
const values = array(value, label).map((item, index) => {
const result = integer(item, `${label}[${index}]`);
if (maximum !== undefined && result > maximum) {
throw new LidarLocalSurfaceContractError(`${label}: значение вне диапазона`);
}
return result;
});
if (values.length !== length) {
throw new LidarLocalSurfaceContractError(`${label}: неверная длина`);
}
return values;
}
function distribution(
value: unknown,
label: string,
@@ -288,6 +388,90 @@ function anchor(value: unknown): LidarLocalSurfaceAnchor {
};
}
function temporalQualification(
value: unknown,
): LidarLocalSurfaceModel["metrics"]["temporalQualification"] {
if (value === undefined || value === null) return null;
const source = record(value, "temporal_qualification");
const prediction = record(source.prediction, "temporal_qualification.prediction");
const stability = record(source.stability, "temporal_qualification.stability");
const steps = record(
source.step_candidates,
"temporal_qualification.step_candidates",
);
if (
prediction.current_frame_excluded !== true
|| steps.is_ground_truth !== false
) {
throw new LidarLocalSurfaceContractError(
"LiDAR temporal qualification завышает evidence",
);
}
const predictionSampleCount = integer(
prediction.sample_count,
"prediction.sample_count",
);
const stabilitySampleCount = integer(
stability.sample_count,
"stability.sample_count",
);
const jumpCount = integer(stability.jump_count, "stability.jump_count");
if (jumpCount > stabilitySampleCount) {
throw new LidarLocalSurfaceContractError(
"LiDAR temporal jump count несовместим",
);
}
return {
prediction: {
currentFrameExcluded: true,
sampleCount: predictionSampleCount,
residualP50M: distribution(
prediction.residual_p50_m,
"prediction.residual_p50_m",
),
residualP95M: distribution(
prediction.residual_p95_m,
"prediction.residual_p95_m",
),
inlierFraction: distribution(
prediction.inlier_fraction,
"prediction.inlier_fraction",
),
},
stability: {
sampleCount: stabilitySampleCount,
heightDeltaM: distribution(
stability.height_delta_m,
"stability.height_delta_m",
),
slopeDeltaDeg: distribution(
stability.slope_delta_deg,
"stability.slope_delta_deg",
),
roughnessDeltaM: distribution(
stability.roughness_delta_m,
"stability.roughness_delta_m",
),
jumpCount,
},
stepCandidates: {
isGroundTruth: false,
framesWithCandidates: integer(
steps.frames_with_candidates,
"step_candidates.frames_with_candidates",
),
cellCount: distribution(
steps.cell_count,
"step_candidates.cell_count",
),
pointCount: distribution(
steps.point_count,
"step_candidates.point_count",
),
},
};
}
function model(value: unknown): LidarLocalSurfaceModel {
const source = record(value, "LiDAR local-surface model");
const sourceEvidence = record(source.source, "source");
@@ -373,6 +557,9 @@ function model(value: unknown): LidarLocalSurfaceModel {
metrics.surface_max_age_ms,
"surface_max_age_ms",
),
temporalQualification: temporalQualification(
metrics.temporal_qualification,
),
},
anchors,
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
@@ -446,10 +633,23 @@ export function parseLidarLocalSurfaceFrame(
const pointHeightM = array(source.point_height_m, "point_height_m").map(
(item, index) => finite(item, `point_height_m[${index}]`),
);
const pointStepCandidate = array(
source.point_step_candidate,
"point_step_candidate",
).map((item, index) => {
const value = integer(item, `point_step_candidate[${index}]`);
if (value > 1) {
throw new LidarLocalSurfaceContractError(
"Некорректная step-candidate mask",
);
}
return value;
});
if (
pointsXyzM.length !== pointCount
|| pointClass.length !== pointCount
|| pointHeightM.length !== pointCount
|| pointStepCandidate.length !== pointCount
) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface point arrays расходятся",
@@ -457,12 +657,15 @@ export function parseLidarLocalSurfaceFrame(
}
const pose = record(source.pose, "pose");
const surface = record(source.surface, "surface");
const prediction = record(source.prediction, "prediction");
const temporal = record(source.temporal, "temporal");
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"),
stepCandidate: integer(counts.step_candidate, "counts.step_candidate"),
};
if (
parsedCounts.classified !== pointClass.filter((item) => item !== 0).length
@@ -470,6 +673,8 @@ export function parseLidarLocalSurfaceFrame(
|| parsedCounts.occupied !== pointClass.filter((item) => item === 2).length
|| parsedCounts.belowSurface
!== pointClass.filter((item) => item === 3).length
|| parsedCounts.stepCandidate
!== pointStepCandidate.filter((item) => item === 1).length
) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface counts расходятся",
@@ -486,6 +691,20 @@ export function parseLidarLocalSurfaceFrame(
4,
"surface.plane_coefficients_map",
);
if (prediction.current_frame_excluded !== true) {
throw new LidarLocalSurfaceContractError(
"Текущий кадр попал в prediction input",
);
}
const predictionInlierFraction = finite(
prediction.inlier_fraction,
"prediction.inlier_fraction",
);
if (predictionInlierFraction < 0 || predictionInlierFraction > 1) {
throw new LidarLocalSurfaceContractError(
"Prediction inlier fraction несовместим",
);
}
return {
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
@@ -503,6 +722,7 @@ export function parseLidarLocalSurfaceFrame(
pointsXyzM,
pointClass,
pointHeightM,
pointStepCandidate,
pose: {
positionXyzM: [position[0], position[1], position[2]],
orientationXyzw: [
@@ -530,12 +750,181 @@ export function parseLidarLocalSurfaceFrame(
),
},
counts: parsedCounts,
prediction: {
available: boolean(prediction.available, "prediction.available"),
currentFrameExcluded: true,
cellCount: integer(prediction.cell_count, "prediction.cell_count"),
residualP50M: finite(
prediction.residual_p50_m,
"prediction.residual_p50_m",
),
residualP95M: finite(
prediction.residual_p95_m,
"prediction.residual_p95_m",
),
inlierFraction: predictionInlierFraction,
},
temporal: {
compared: boolean(temporal.compared, "temporal.compared"),
heightDeltaM: finite(
temporal.height_delta_m,
"temporal.height_delta_m",
),
slopeDeltaDeg: finite(
temporal.slope_delta_deg,
"temporal.slope_delta_deg",
),
roughnessDeltaM: finite(
temporal.roughness_delta_m,
"temporal.roughness_delta_m",
),
jump: boolean(temporal.jump, "temporal.jump"),
stepCandidateCellCount: integer(
temporal.step_candidate_cell_count,
"temporal.step_candidate_cell_count",
),
},
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
groundTruth: false,
authority: authority(source.authority),
};
}
export function parseLidarLocalSurfaceTimeline(
value: unknown,
): LidarLocalSurfaceTimeline {
const source = record(value, "LiDAR local-surface timeline");
if (
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-timeline/v1`
|| source.access !== "read-only"
|| source.ground_truth !== false
) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface timeline несовместим",
);
}
const frameCount = integer(source.frame_count, "frame_count");
if (frameCount < 1 || frameCount > 100_000) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface timeline слишком большой",
);
}
const sourceFrameIndex = integerVector(
source.source_frame_index,
frameCount,
"source_frame_index",
);
const sessionSeconds = finiteVector(
source.session_seconds,
frameCount,
"session_seconds",
);
const sourceAvailable = integerVector(
source.source_available,
frameCount,
"source_available",
1,
);
const valid = integerVector(source.valid, frameCount, "valid", 1);
const predictionAvailable = integerVector(
source.prediction_available,
frameCount,
"prediction_available",
1,
);
const predictionResidualP50M = finiteVector(
source.prediction_residual_p50_m,
frameCount,
"prediction_residual_p50_m",
);
const predictionResidualP95M = finiteVector(
source.prediction_residual_p95_m,
frameCount,
"prediction_residual_p95_m",
);
const predictionInlierFraction = finiteVector(
source.prediction_inlier_fraction,
frameCount,
"prediction_inlier_fraction",
);
const confidence = finiteVector(source.confidence, frameCount, "confidence");
const temporalCompared = integerVector(
source.temporal_compared,
frameCount,
"temporal_compared",
1,
);
const temporalJump = integerVector(
source.temporal_jump,
frameCount,
"temporal_jump",
1,
);
if (
sourceFrameIndex.some(
(item, index) => index > 0 && item <= sourceFrameIndex[index - 1],
)
|| sessionSeconds.some(
(item, index) => index > 0 && item <= sessionSeconds[index - 1],
)
|| predictionInlierFraction.some((item) => item < 0 || item > 1)
|| confidence.some((item) => item < 0 || item > 1)
|| temporalJump.some(
(item, index) => item === 1 && temporalCompared[index] !== 1,
)
) {
throw new LidarLocalSurfaceContractError(
"LiDAR local-surface timeline content несовместим",
);
}
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),
frameCount,
sourceFrameIndex,
sessionSeconds,
sourceAvailable,
valid,
predictionAvailable,
predictionResidualP50M,
predictionResidualP95M,
predictionInlierFraction,
sensorHeightM: finiteVector(
source.sensor_height_m,
frameCount,
"sensor_height_m",
),
slopeDeg: finiteVector(source.slope_deg, frameCount, "slope_deg"),
roughnessM: finiteVector(source.roughness_m, frameCount, "roughness_m"),
confidence,
temporalCompared,
heightDeltaM: finiteVector(
source.height_delta_m,
frameCount,
"height_delta_m",
),
slopeDeltaDeg: finiteVector(
source.slope_delta_deg,
frameCount,
"slope_delta_deg",
),
roughnessDeltaM: finiteVector(
source.roughness_delta_m,
frameCount,
"roughness_delta_m",
),
temporalJump,
stepCandidatePointCount: integerVector(
source.step_candidate_point_count,
frameCount,
"step_candidate_point_count",
),
groundTruth: false,
authority: authority(source.authority),
};
}
async function responseJson(
response: Response,
fallback: string,
@@ -600,3 +989,29 @@ export async function fetchLidarLocalSurfaceFrame(
),
);
}
export async function fetchLidarLocalSurfaceTimeline(
modelId: string,
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
): Promise<LidarLocalSurfaceTimeline> {
if (!SAFE_MODEL_ID.test(modelId)) {
throw new LidarLocalSurfaceContractError(
"Некорректный LiDAR local-surface timeline",
);
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/lidar/local-surfaces/${modelId}/timeline`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseLidarLocalSurfaceTimeline(
await responseJson(
response,
"Не удалось получить LiDAR local-surface timeline.",
),
);
}