feat: qualify K1 local surface over time
This commit is contained in:
parent
04a658b218
commit
3449b2bc3e
|
|
@ -145,7 +145,11 @@ estimates a rolling local surface from map points plus compatible pose, and
|
||||||
publishes height, slope, roughness, confidence and conservative observed
|
publishes height, slope, roughness, confidence and conservative observed
|
||||||
surface/occupied/unknown evidence. All `526/526` available samples produced a
|
surface/occupied/unknown evidence. All `526/526` available samples produced a
|
||||||
diagnostic result; no free-space, command, navigation or safety authority is
|
diagnostic result; no free-space, command, navigation or safety authority is
|
||||||
inferred. The selected scene is visible in **Парк → Диагностика LiDAR**.
|
inferred. Leave-current-frame-out qualification produced `525` next-frame
|
||||||
|
samples with `0.038543 m` p50 median residual and `12` strict temporal jumps;
|
||||||
|
the p95 residual tail remains too large for planner use. The selected scene and
|
||||||
|
clickable complete-recording timeline are 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
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,28 @@ export interface LidarLocalSurfaceModel {
|
||||||
confidence: LidarLocalSurfaceDistribution;
|
confidence: LidarLocalSurfaceDistribution;
|
||||||
poseBindingAgeMs: LidarLocalSurfaceDistribution;
|
poseBindingAgeMs: LidarLocalSurfaceDistribution;
|
||||||
surfaceMaxAgeMs: 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[];
|
anchors: LidarLocalSurfaceAnchor[];
|
||||||
occupancyPolicy: {
|
occupancyPolicy: {
|
||||||
|
|
@ -88,6 +110,7 @@ export interface LidarLocalSurfaceFrame {
|
||||||
pointsXyzM: Array<[number, number, number]>;
|
pointsXyzM: Array<[number, number, number]>;
|
||||||
pointClass: number[];
|
pointClass: number[];
|
||||||
pointHeightM: number[];
|
pointHeightM: number[];
|
||||||
|
pointStepCandidate: number[];
|
||||||
pose: {
|
pose: {
|
||||||
positionXyzM: [number, number, number];
|
positionXyzM: [number, number, number];
|
||||||
orientationXyzw: [number, number, number, number];
|
orientationXyzw: [number, number, number, number];
|
||||||
|
|
@ -108,12 +131,56 @@ export interface LidarLocalSurfaceFrame {
|
||||||
surface: number;
|
surface: number;
|
||||||
occupied: number;
|
occupied: number;
|
||||||
belowSurface: 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"];
|
occupancyPolicy: LidarLocalSurfaceModel["occupancyPolicy"];
|
||||||
groundTruth: false;
|
groundTruth: false;
|
||||||
authority: LidarLocalSurfaceModel["authority"];
|
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 LidarLocalSurfaceContractError extends Error {}
|
||||||
|
|
||||||
export class LidarLocalSurfaceApiError extends Error {
|
export class LidarLocalSurfaceApiError extends Error {
|
||||||
|
|
@ -199,6 +266,39 @@ function tuple(
|
||||||
return values;
|
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(
|
function distribution(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
label: string,
|
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 {
|
function model(value: unknown): LidarLocalSurfaceModel {
|
||||||
const source = record(value, "LiDAR local-surface model");
|
const source = record(value, "LiDAR local-surface model");
|
||||||
const sourceEvidence = record(source.source, "source");
|
const sourceEvidence = record(source.source, "source");
|
||||||
|
|
@ -373,6 +557,9 @@ function model(value: unknown): LidarLocalSurfaceModel {
|
||||||
metrics.surface_max_age_ms,
|
metrics.surface_max_age_ms,
|
||||||
"surface_max_age_ms",
|
"surface_max_age_ms",
|
||||||
),
|
),
|
||||||
|
temporalQualification: temporalQualification(
|
||||||
|
metrics.temporal_qualification,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
anchors,
|
anchors,
|
||||||
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
|
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
|
||||||
|
|
@ -446,10 +633,23 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
const pointHeightM = array(source.point_height_m, "point_height_m").map(
|
const pointHeightM = array(source.point_height_m, "point_height_m").map(
|
||||||
(item, index) => finite(item, `point_height_m[${index}]`),
|
(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 (
|
if (
|
||||||
pointsXyzM.length !== pointCount
|
pointsXyzM.length !== pointCount
|
||||||
|| pointClass.length !== pointCount
|
|| pointClass.length !== pointCount
|
||||||
|| pointHeightM.length !== pointCount
|
|| pointHeightM.length !== pointCount
|
||||||
|
|| pointStepCandidate.length !== pointCount
|
||||||
) {
|
) {
|
||||||
throw new LidarLocalSurfaceContractError(
|
throw new LidarLocalSurfaceContractError(
|
||||||
"LiDAR local-surface point arrays расходятся",
|
"LiDAR local-surface point arrays расходятся",
|
||||||
|
|
@ -457,12 +657,15 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
}
|
}
|
||||||
const pose = record(source.pose, "pose");
|
const pose = record(source.pose, "pose");
|
||||||
const surface = record(source.surface, "surface");
|
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 counts = record(source.counts, "counts");
|
||||||
const parsedCounts = {
|
const parsedCounts = {
|
||||||
classified: integer(counts.classified, "counts.classified"),
|
classified: integer(counts.classified, "counts.classified"),
|
||||||
surface: integer(counts.surface, "counts.surface"),
|
surface: integer(counts.surface, "counts.surface"),
|
||||||
occupied: integer(counts.occupied, "counts.occupied"),
|
occupied: integer(counts.occupied, "counts.occupied"),
|
||||||
belowSurface: integer(counts.below_surface, "counts.below_surface"),
|
belowSurface: integer(counts.below_surface, "counts.below_surface"),
|
||||||
|
stepCandidate: integer(counts.step_candidate, "counts.step_candidate"),
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
parsedCounts.classified !== pointClass.filter((item) => item !== 0).length
|
parsedCounts.classified !== pointClass.filter((item) => item !== 0).length
|
||||||
|
|
@ -470,6 +673,8 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
|| parsedCounts.occupied !== pointClass.filter((item) => item === 2).length
|
|| parsedCounts.occupied !== pointClass.filter((item) => item === 2).length
|
||||||
|| parsedCounts.belowSurface
|
|| parsedCounts.belowSurface
|
||||||
!== pointClass.filter((item) => item === 3).length
|
!== pointClass.filter((item) => item === 3).length
|
||||||
|
|| parsedCounts.stepCandidate
|
||||||
|
!== pointStepCandidate.filter((item) => item === 1).length
|
||||||
) {
|
) {
|
||||||
throw new LidarLocalSurfaceContractError(
|
throw new LidarLocalSurfaceContractError(
|
||||||
"LiDAR local-surface counts расходятся",
|
"LiDAR local-surface counts расходятся",
|
||||||
|
|
@ -486,6 +691,20 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
4,
|
4,
|
||||||
"surface.plane_coefficients_map",
|
"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 {
|
return {
|
||||||
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
||||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||||
|
|
@ -503,6 +722,7 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
pointsXyzM,
|
pointsXyzM,
|
||||||
pointClass,
|
pointClass,
|
||||||
pointHeightM,
|
pointHeightM,
|
||||||
|
pointStepCandidate,
|
||||||
pose: {
|
pose: {
|
||||||
positionXyzM: [position[0], position[1], position[2]],
|
positionXyzM: [position[0], position[1], position[2]],
|
||||||
orientationXyzw: [
|
orientationXyzw: [
|
||||||
|
|
@ -530,12 +750,181 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
counts: parsedCounts,
|
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),
|
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
|
||||||
groundTruth: false,
|
groundTruth: false,
|
||||||
authority: authority(source.authority),
|
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(
|
async function responseJson(
|
||||||
response: Response,
|
response: Response,
|
||||||
fallback: string,
|
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.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2884,6 +2884,97 @@
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.38rem;
|
||||||
|
background: rgb(255 255 255 / 0.018);
|
||||||
|
padding: 0.62rem 0.68rem 0.48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline header,
|
||||||
|
.lidar-local-surface__timeline footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline header > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline header > div:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline span,
|
||||||
|
.lidar-local-surface__timeline small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 8.3rem;
|
||||||
|
cursor: crosshair;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline svg:focus-visible {
|
||||||
|
background: rgb(255 255 255 / 0.018);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline-baseline {
|
||||||
|
stroke: rgb(255 255 255 / 0.08);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline-reference {
|
||||||
|
stroke: rgb(255 255 255 / 0.08);
|
||||||
|
stroke-dasharray: 4 8;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: #a1b87d;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline-jump {
|
||||||
|
stroke: #f5c23d;
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline-selected {
|
||||||
|
stroke: rgb(255 255 255 / 0.88);
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline footer span:nth-child(2) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__timeline footer i {
|
||||||
|
width: 0.42rem;
|
||||||
|
height: 0.42rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f5c23d;
|
||||||
|
}
|
||||||
|
|
||||||
.lidar-local-surface__stage {
|
.lidar-local-surface__stage {
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -2953,6 +3044,10 @@
|
||||||
background: #a1b87d;
|
background: #a1b87d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lidar-local-surface__legend i[data-class="step"] {
|
||||||
|
background: #f5c23d;
|
||||||
|
}
|
||||||
|
|
||||||
.lidar-local-surface__legend i[data-class="occupied"] {
|
.lidar-local-surface__legend i[data-class="occupied"] {
|
||||||
background: #f0783d;
|
background: #f0783d;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface LidarGroundPointCloudFrame {
|
||||||
groundTruthGround?: number[];
|
groundTruthGround?: number[];
|
||||||
evaluationMask?: number[];
|
evaluationMask?: number[];
|
||||||
localSurfaceClass?: number[];
|
localSurfaceClass?: number[];
|
||||||
|
localStepCandidate?: number[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,7 +72,10 @@ function frameColors(
|
||||||
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||||
if (mode === "local-surface") {
|
if (mode === "local-surface") {
|
||||||
const localClass = frame.masks.localSurfaceClass?.[index] ?? 0;
|
const localClass = frame.masks.localSurfaceClass?.[index] ?? 0;
|
||||||
if (localClass === 1) {
|
const stepCandidate = frame.masks.localStepCandidate?.[index] === 1;
|
||||||
|
if (stepCandidate) {
|
||||||
|
setRgb(colors, offset, 0.96, 0.76, 0.24);
|
||||||
|
} else if (localClass === 1) {
|
||||||
setRgb(colors, offset, 0.63, 0.72, 0.49);
|
setRgb(colors, offset, 0.63, 0.72, 0.49);
|
||||||
} else if (localClass === 2) {
|
} else if (localClass === 2) {
|
||||||
setRgb(colors, offset, 0.94, 0.48, 0.24);
|
setRgb(colors, offset, 0.94, 0.48, 0.24);
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@ import { StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchLidarLocalSurfaceFrame,
|
fetchLidarLocalSurfaceFrame,
|
||||||
|
fetchLidarLocalSurfaceTimeline,
|
||||||
fetchLidarLocalSurfaces,
|
fetchLidarLocalSurfaces,
|
||||||
type LidarLocalSurfaceFrame,
|
type LidarLocalSurfaceFrame,
|
||||||
type LidarLocalSurfaceModel,
|
type LidarLocalSurfaceModel,
|
||||||
|
type LidarLocalSurfaceTimeline as Timeline,
|
||||||
} from "../core/lidar/localSurface";
|
} from "../core/lidar/localSurface";
|
||||||
import { LidarGroundPointCloud } from "./LidarGroundPointCloud";
|
import { LidarGroundPointCloud } from "./LidarGroundPointCloud";
|
||||||
|
import { LidarLocalSurfaceTimeline } from "./LidarLocalSurfaceTimeline";
|
||||||
|
|
||||||
function formatNumber(value: number | null, digits = 2): string {
|
function formatNumber(value: number | null, digits = 2): string {
|
||||||
if (value === null) return "—";
|
if (value === null) return "—";
|
||||||
|
|
@ -29,6 +32,10 @@ export function LidarLocalSurfacePanel({
|
||||||
}) {
|
}) {
|
||||||
const [model, setModel] = useState<LidarLocalSurfaceModel | null>(null);
|
const [model, setModel] = useState<LidarLocalSurfaceModel | null>(null);
|
||||||
const [frame, setFrame] = useState<LidarLocalSurfaceFrame | null>(null);
|
const [frame, setFrame] = useState<LidarLocalSurfaceFrame | null>(null);
|
||||||
|
const [timeline, setTimeline] = useState<Timeline | null>(null);
|
||||||
|
const [selectedFrameIndex, setSelectedFrameIndex] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -48,12 +55,19 @@ export function LidarLocalSurfacePanel({
|
||||||
void fetchLidarLocalSurfaces({ signal: controller.signal })
|
void fetchLidarLocalSurfaces({ signal: controller.signal })
|
||||||
.then((catalog) => {
|
.then((catalog) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setModel(catalog.items[0] ?? null);
|
setModel(
|
||||||
|
catalog.items.find(
|
||||||
|
(item) => item.metrics.temporalQualification !== null,
|
||||||
|
)
|
||||||
|
?? catalog.items[0]
|
||||||
|
?? null,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((loadError) => {
|
.catch((loadError) => {
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
setModel(null);
|
setModel(null);
|
||||||
setFrame(null);
|
setFrame(null);
|
||||||
|
setTimeline(null);
|
||||||
setError(errorMessage(loadError));
|
setError(errorMessage(loadError));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -63,14 +77,38 @@ export function LidarLocalSurfacePanel({
|
||||||
}, [reloadGeneration]);
|
}, [reloadGeneration]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!model || !anchor) {
|
setSelectedFrameIndex(anchor?.frameIndex ?? null);
|
||||||
|
}, [anchor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model) {
|
||||||
|
setTimeline(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
void fetchLidarLocalSurfaceTimeline(model.modelId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then((nextTimeline) => {
|
||||||
|
if (!controller.signal.aborted) setTimeline(nextTimeline);
|
||||||
|
})
|
||||||
|
.catch((loadError) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setTimeline(null);
|
||||||
|
setError(errorMessage(loadError));
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model || selectedFrameIndex === null) {
|
||||||
setFrame(null);
|
setFrame(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
void fetchLidarLocalSurfaceFrame(model.modelId, anchor.frameIndex, {
|
void fetchLidarLocalSurfaceFrame(model.modelId, selectedFrameIndex, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then((nextFrame) => {
|
.then((nextFrame) => {
|
||||||
|
|
@ -85,7 +123,7 @@ export function LidarLocalSurfacePanel({
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [anchor, model]);
|
}, [model, selectedFrameIndex]);
|
||||||
|
|
||||||
const cloudFrame = useMemo(() => {
|
const cloudFrame = useMemo(() => {
|
||||||
if (!frame) return null;
|
if (!frame) return null;
|
||||||
|
|
@ -101,6 +139,7 @@ export function LidarLocalSurfacePanel({
|
||||||
candidateAssigned: emptyMask,
|
candidateAssigned: emptyMask,
|
||||||
disagreement: emptyMask,
|
disagreement: emptyMask,
|
||||||
localSurfaceClass: frame.pointClass,
|
localSurfaceClass: frame.pointClass,
|
||||||
|
localStepCandidate: frame.pointStepCandidate,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [frame]);
|
}, [frame]);
|
||||||
|
|
@ -119,15 +158,27 @@ export function LidarLocalSurfacePanel({
|
||||||
<span className="section-eyebrow">ЛОКАЛЬНАЯ МОДЕЛЬ LIDAR · L2.6</span>
|
<span className="section-eyebrow">ЛОКАЛЬНАЯ МОДЕЛЬ LIDAR · L2.6</span>
|
||||||
<h3>Поверхность и наблюдаемые препятствия</h3>
|
<h3>Поверхность и наблюдаемые препятствия</h3>
|
||||||
<p>
|
<p>
|
||||||
Производная от неизменяемого RAVNOVES00: высота и уклон
|
Поверхность строится по предыдущему TTL-окну и проверяется на
|
||||||
вычисляются из текущей позы и локальной поверхности, без константы
|
следующем кадре. Текущий кадр исключён из prediction input;
|
||||||
1,27 м и без команд в сканер.
|
константа 1,27 м и команды в сканер не используются.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={error ? "danger" : frame?.valid ? "success" : "warning"}>
|
<StatusBadge
|
||||||
|
tone={
|
||||||
|
error
|
||||||
|
? "danger"
|
||||||
|
: frame?.temporal.jump
|
||||||
|
? "warning"
|
||||||
|
: frame?.valid
|
||||||
|
? "success"
|
||||||
|
: "warning"
|
||||||
|
}
|
||||||
|
>
|
||||||
{error
|
{error
|
||||||
? "Недоступно"
|
? "Недоступно"
|
||||||
: frame?.valid
|
: frame?.temporal.jump
|
||||||
|
? "Temporal jump"
|
||||||
|
: frame?.valid
|
||||||
? "Кадр рассчитан"
|
? "Кадр рассчитан"
|
||||||
: "Диагностический режим"}
|
: "Диагностический режим"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
|
|
@ -143,20 +194,46 @@ export function LidarLocalSurfacePanel({
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Высота над поверхностью · p50</span>
|
<span>Prediction residual · p50</span>
|
||||||
<strong>{formatNumber(model.metrics.sensorHeightM.p50)} м</strong>
|
<strong>
|
||||||
|
{formatNumber(
|
||||||
|
model.metrics.temporalQualification?.prediction
|
||||||
|
.residualP50M.p50 ?? null,
|
||||||
|
3,
|
||||||
|
)}{" "}
|
||||||
|
м
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Шероховатость · p95</span>
|
<span>Prediction inliers · p50</span>
|
||||||
<strong>{formatNumber(model.metrics.roughnessM.p95, 3)} м</strong>
|
<strong>
|
||||||
|
{formatNumber(
|
||||||
|
(model.metrics.temporalQualification?.prediction
|
||||||
|
.inlierFraction.p50 ?? 0) * 100,
|
||||||
|
1,
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Pose binding · p95</span>
|
<span>Temporal jumps</span>
|
||||||
<strong>{formatNumber(model.metrics.poseBindingAgeMs.p95)} мс</strong>
|
<strong>
|
||||||
|
{model.metrics.temporalQualification?.stability.jumpCount ?? 0}
|
||||||
|
{" / "}
|
||||||
|
{model.metrics.temporalQualification?.stability.sampleCount ?? 0}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{timeline && selectedFrameIndex !== null ? (
|
||||||
|
<LidarLocalSurfaceTimeline
|
||||||
|
timeline={timeline}
|
||||||
|
selectedFrameIndex={selectedFrameIndex}
|
||||||
|
onSelectFrame={setSelectedFrameIndex}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="lidar-local-surface__stage">
|
<div className="lidar-local-surface__stage">
|
||||||
{cloudFrame && frame ? (
|
{cloudFrame && frame ? (
|
||||||
<LidarGroundPointCloud frame={cloudFrame} mode="local-surface" />
|
<LidarGroundPointCloud frame={cloudFrame} mode="local-surface" />
|
||||||
|
|
@ -192,6 +269,22 @@ export function LidarLocalSurfacePanel({
|
||||||
<dt>Confidence</dt>
|
<dt>Confidence</dt>
|
||||||
<dd>{formatNumber(frame.surface.confidence * 100, 0)}%</dd>
|
<dd>{formatNumber(frame.surface.confidence * 100, 0)}%</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Prediction p50</dt>
|
||||||
|
<dd>
|
||||||
|
{frame.prediction.available
|
||||||
|
? `${formatNumber(frame.prediction.residualP50M, 3)} м`
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Prediction inliers</dt>
|
||||||
|
<dd>
|
||||||
|
{frame.prediction.available
|
||||||
|
? `${formatNumber(frame.prediction.inlierFraction * 100, 1)}%`
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Поверхность</dt>
|
<dt>Поверхность</dt>
|
||||||
<dd>{frame.counts.surface.toLocaleString("ru-RU")} точек</dd>
|
<dd>{frame.counts.surface.toLocaleString("ru-RU")} точек</dd>
|
||||||
|
|
@ -200,16 +293,24 @@ export function LidarLocalSurfacePanel({
|
||||||
<dt>Препятствия</dt>
|
<dt>Препятствия</dt>
|
||||||
<dd>{frame.counts.occupied.toLocaleString("ru-RU")} точек</dd>
|
<dd>{frame.counts.occupied.toLocaleString("ru-RU")} точек</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Перепады-кандидаты</dt>
|
||||||
|
<dd>
|
||||||
|
{frame.counts.stepCandidate.toLocaleString("ru-RU")} точек
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="lidar-local-surface__legend">
|
<div className="lidar-local-surface__legend">
|
||||||
|
<span><i data-class="step" />Перепад / бордюр-кандидат</span>
|
||||||
<span><i data-class="surface" />Наблюдаемая поверхность</span>
|
<span><i data-class="surface" />Наблюдаемая поверхность</span>
|
||||||
<span><i data-class="occupied" />Выше поверхности</span>
|
<span><i data-class="occupied" />Выше поверхности</span>
|
||||||
<span><i data-class="below" />Нижний выброс</span>
|
<span><i data-class="below" />Нижний выброс</span>
|
||||||
<span><i data-class="unknown" />Не классифицировано</span>
|
<span><i data-class="unknown" />Не классифицировано</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Пустота между точками остаётся unknown. Этот слой не разрешает
|
Жёлтый слой — геометрический кандидат, не распознанный бордюр и
|
||||||
движение и не меняет постоянную реконструкцию территории.
|
не ground truth. Пустота остаётся unknown; движение этим слоем
|
||||||
|
не разрешается.
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { LidarLocalSurfaceTimeline as Timeline } from "../core/lidar/localSurface";
|
||||||
|
|
||||||
|
const VIEWBOX_WIDTH = 1000;
|
||||||
|
const VIEWBOX_HEIGHT = 168;
|
||||||
|
const PLOT_TOP = 22;
|
||||||
|
const PLOT_BOTTOM = 132;
|
||||||
|
|
||||||
|
function formatMeters(value: number): string {
|
||||||
|
return value.toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 3,
|
||||||
|
maximumFractionDigits: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function xAt(index: number, frameCount: number): number {
|
||||||
|
if (frameCount <= 1) return 0;
|
||||||
|
return (index / (frameCount - 1)) * VIEWBOX_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LidarLocalSurfaceTimeline({
|
||||||
|
timeline,
|
||||||
|
selectedFrameIndex,
|
||||||
|
onSelectFrame,
|
||||||
|
}: {
|
||||||
|
timeline: Timeline;
|
||||||
|
selectedFrameIndex: number;
|
||||||
|
onSelectFrame: (frameIndex: number) => void;
|
||||||
|
}) {
|
||||||
|
const plot = useMemo(() => {
|
||||||
|
const samples = timeline.predictionResidualP50M.filter(
|
||||||
|
(value, index) => timeline.predictionAvailable[index] === 1
|
||||||
|
&& Number.isFinite(value),
|
||||||
|
);
|
||||||
|
const sorted = [...samples].sort((left, right) => left - right);
|
||||||
|
const robustMaximum = sorted.length
|
||||||
|
? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.98))]
|
||||||
|
: 0;
|
||||||
|
const scaleMaximum = Math.max(0.06, robustMaximum * 1.15);
|
||||||
|
const points = timeline.predictionResidualP50M
|
||||||
|
.map((value, index) => {
|
||||||
|
if (timeline.predictionAvailable[index] !== 1) return null;
|
||||||
|
const bounded = Math.min(scaleMaximum, Math.max(0, value));
|
||||||
|
const y = PLOT_BOTTOM
|
||||||
|
- (bounded / scaleMaximum) * (PLOT_BOTTOM - PLOT_TOP);
|
||||||
|
return `${xAt(index, timeline.frameCount).toFixed(2)},${y.toFixed(2)}`;
|
||||||
|
})
|
||||||
|
.filter((value): value is string => value !== null)
|
||||||
|
.join(" ");
|
||||||
|
return { points, scaleMaximum };
|
||||||
|
}, [timeline]);
|
||||||
|
|
||||||
|
const selectedIndex = Math.min(
|
||||||
|
timeline.frameCount - 1,
|
||||||
|
Math.max(0, selectedFrameIndex),
|
||||||
|
);
|
||||||
|
const selectedResidual = timeline.predictionAvailable[selectedIndex] === 1
|
||||||
|
? timeline.predictionResidualP50M[selectedIndex]
|
||||||
|
: null;
|
||||||
|
const jumpCount = timeline.temporalJump.reduce(
|
||||||
|
(total, value) => total + value,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAtPointer = (clientX: number, target: SVGSVGElement) => {
|
||||||
|
const bounds = target.getBoundingClientRect();
|
||||||
|
if (bounds.width <= 0) return;
|
||||||
|
const fraction = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, (clientX - bounds.left) / bounds.width),
|
||||||
|
);
|
||||||
|
onSelectFrame(Math.round(fraction * (timeline.frameCount - 1)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const referenceY = PLOT_BOTTOM
|
||||||
|
- (0.04 / plot.scaleMaximum) * (PLOT_BOTTOM - PLOT_TOP);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lidar-local-surface__timeline">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span>Вся запись · current frame excluded</span>
|
||||||
|
<strong>Ошибка предсказания поверхности</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
кадр {timeline.sourceFrameIndex[selectedIndex]}
|
||||||
|
{" · "}
|
||||||
|
{selectedResidual === null
|
||||||
|
? "нет prediction"
|
||||||
|
: `${formatMeters(selectedResidual)} м`}
|
||||||
|
</span>
|
||||||
|
<small>{jumpCount} temporal jumps</small>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Ошибка предсказания локальной поверхности по всей записи"
|
||||||
|
onClick={(event) => selectAtPointer(event.clientX, event.currentTarget)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelectFrame(Math.max(0, selectedIndex - 1));
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelectFrame(Math.min(timeline.frameCount - 1, selectedIndex + 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
className="lidar-local-surface__timeline-baseline"
|
||||||
|
x1="0"
|
||||||
|
x2={VIEWBOX_WIDTH}
|
||||||
|
y1={PLOT_BOTTOM}
|
||||||
|
y2={PLOT_BOTTOM}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
className="lidar-local-surface__timeline-reference"
|
||||||
|
x1="0"
|
||||||
|
x2={VIEWBOX_WIDTH}
|
||||||
|
y1={referenceY}
|
||||||
|
y2={referenceY}
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
className="lidar-local-surface__timeline-line"
|
||||||
|
points={plot.points}
|
||||||
|
/>
|
||||||
|
{timeline.temporalJump.map((value, index) =>
|
||||||
|
value === 1 ? (
|
||||||
|
<line
|
||||||
|
className="lidar-local-surface__timeline-jump"
|
||||||
|
key={timeline.sourceFrameIndex[index]}
|
||||||
|
x1={xAt(index, timeline.frameCount)}
|
||||||
|
x2={xAt(index, timeline.frameCount)}
|
||||||
|
y1={PLOT_TOP}
|
||||||
|
y2={PLOT_BOTTOM}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
<line
|
||||||
|
className="lidar-local-surface__timeline-selected"
|
||||||
|
x1={xAt(selectedIndex, timeline.frameCount)}
|
||||||
|
x2={xAt(selectedIndex, timeline.frameCount)}
|
||||||
|
y1="8"
|
||||||
|
y2="148"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<footer>
|
||||||
|
<span>начало</span>
|
||||||
|
<span><i /> скачок модели</span>
|
||||||
|
<span>конец</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import { createServer } from "vite";
|
||||||
let server;
|
let server;
|
||||||
let parseLidarLocalSurfaceCatalog;
|
let parseLidarLocalSurfaceCatalog;
|
||||||
let parseLidarLocalSurfaceFrame;
|
let parseLidarLocalSurfaceFrame;
|
||||||
|
let parseLidarLocalSurfaceTimeline;
|
||||||
|
let fetchLidarLocalSurfaceTimeline;
|
||||||
let LidarLocalSurfaceContractError;
|
let LidarLocalSurfaceContractError;
|
||||||
|
|
||||||
const modelId = `k1-local-surface-${"a".repeat(64)}`;
|
const modelId = `k1-local-surface-${"a".repeat(64)}`;
|
||||||
|
|
@ -71,6 +73,28 @@ function model(overrides = {}) {
|
||||||
confidence: distribution(0.8),
|
confidence: distribution(0.8),
|
||||||
pose_binding_age_ms: distribution(7),
|
pose_binding_age_ms: distribution(7),
|
||||||
surface_max_age_ms: distribution(1000),
|
surface_max_age_ms: distribution(1000),
|
||||||
|
temporal_qualification: {
|
||||||
|
prediction: {
|
||||||
|
current_frame_excluded: true,
|
||||||
|
sample_count: 9,
|
||||||
|
residual_p50_m: distribution(0.04),
|
||||||
|
residual_p95_m: distribution(0.2),
|
||||||
|
inlier_fraction: distribution(0.95),
|
||||||
|
},
|
||||||
|
stability: {
|
||||||
|
sample_count: 9,
|
||||||
|
height_delta_m: distribution(0.01),
|
||||||
|
slope_delta_deg: distribution(0.1),
|
||||||
|
roughness_delta_m: distribution(0.002),
|
||||||
|
jump_count: 1,
|
||||||
|
},
|
||||||
|
step_candidates: {
|
||||||
|
is_ground_truth: false,
|
||||||
|
frames_with_candidates: 8,
|
||||||
|
cell_count: distribution(20),
|
||||||
|
point_count: distribution(12),
|
||||||
|
},
|
||||||
|
},
|
||||||
build_elapsed_ms: 20,
|
build_elapsed_ms: 20,
|
||||||
},
|
},
|
||||||
anchors: [{
|
anchors: [{
|
||||||
|
|
@ -127,6 +151,7 @@ function frame(overrides = {}) {
|
||||||
points_xyz_m: [[0, 0, 0], [1, 0, 0], [1, 1, 0.7], [2, 0, -0.3]],
|
points_xyz_m: [[0, 0, 0], [1, 0, 0], [1, 1, 0.7], [2, 0, -0.3]],
|
||||||
point_class: [1, 1, 2, 3],
|
point_class: [1, 1, 2, 3],
|
||||||
point_height_m: [0, 0.02, 0.7, -0.3],
|
point_height_m: [0, 0.02, 0.7, -0.3],
|
||||||
|
point_step_candidate: [0, 1, 0, 0],
|
||||||
pose: {
|
pose: {
|
||||||
position_xyz_m: [0, 0, 1.3],
|
position_xyz_m: [0, 0, 1.3],
|
||||||
orientation_xyzw: [0, 0, 0, 1],
|
orientation_xyzw: [0, 0, 0, 1],
|
||||||
|
|
@ -147,6 +172,23 @@ function frame(overrides = {}) {
|
||||||
surface: 2,
|
surface: 2,
|
||||||
occupied: 1,
|
occupied: 1,
|
||||||
below_surface: 1,
|
below_surface: 1,
|
||||||
|
step_candidate: 1,
|
||||||
|
},
|
||||||
|
prediction: {
|
||||||
|
available: true,
|
||||||
|
current_frame_excluded: true,
|
||||||
|
cell_count: 32,
|
||||||
|
residual_p50_m: 0.04,
|
||||||
|
residual_p95_m: 0.2,
|
||||||
|
inlier_fraction: 0.95,
|
||||||
|
},
|
||||||
|
temporal: {
|
||||||
|
compared: true,
|
||||||
|
height_delta_m: 0.01,
|
||||||
|
slope_delta_deg: 0.1,
|
||||||
|
roughness_delta_m: 0.002,
|
||||||
|
jump: false,
|
||||||
|
step_candidate_cell_count: 5,
|
||||||
},
|
},
|
||||||
classes: {},
|
classes: {},
|
||||||
occupancy_policy: policy(),
|
occupancy_policy: policy(),
|
||||||
|
|
@ -160,6 +202,41 @@ function frame(overrides = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timeline(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.k1-local-surface-timeline/v1",
|
||||||
|
model_id: modelId,
|
||||||
|
source_pack_id: sourcePackId,
|
||||||
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
|
frame_count: 4,
|
||||||
|
source_frame_index: [1000, 1001, 1002, 1003],
|
||||||
|
session_seconds: [0, 0.1, 0.2, 0.3],
|
||||||
|
source_available: [1, 1, 1, 1],
|
||||||
|
valid: [1, 1, 1, 1],
|
||||||
|
prediction_available: [0, 1, 1, 1],
|
||||||
|
prediction_residual_p50_m: [0, 0.04, 0.05, 0.03],
|
||||||
|
prediction_residual_p95_m: [0, 0.2, 0.3, 0.15],
|
||||||
|
prediction_inlier_fraction: [0, 0.95, 0.92, 0.97],
|
||||||
|
sensor_height_m: [1.3, 1.31, 1.3, 1.29],
|
||||||
|
slope_deg: [1, 1.1, 1.2, 1],
|
||||||
|
roughness_m: [0.04, 0.04, 0.05, 0.04],
|
||||||
|
confidence: [0.8, 0.8, 0.75, 0.82],
|
||||||
|
temporal_compared: [0, 1, 1, 1],
|
||||||
|
height_delta_m: [0, 0.01, 0.01, 0.01],
|
||||||
|
slope_delta_deg: [0, 0.1, 0.1, 0.2],
|
||||||
|
roughness_delta_m: [0, 0, 0.01, 0.01],
|
||||||
|
temporal_jump: [0, 0, 1, 0],
|
||||||
|
step_candidate_point_count: [1, 2, 3, 4],
|
||||||
|
ground_truth: false,
|
||||||
|
access: "read-only",
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
appType: "custom",
|
appType: "custom",
|
||||||
|
|
@ -169,6 +246,8 @@ before(async () => {
|
||||||
({
|
({
|
||||||
parseLidarLocalSurfaceCatalog,
|
parseLidarLocalSurfaceCatalog,
|
||||||
parseLidarLocalSurfaceFrame,
|
parseLidarLocalSurfaceFrame,
|
||||||
|
parseLidarLocalSurfaceTimeline,
|
||||||
|
fetchLidarLocalSurfaceTimeline,
|
||||||
LidarLocalSurfaceContractError,
|
LidarLocalSurfaceContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/lidar/localSurface.ts"));
|
} = await server.ssrLoadModule("/src/core/lidar/localSurface.ts"));
|
||||||
});
|
});
|
||||||
|
|
@ -182,10 +261,21 @@ test("decodes passive local-surface evidence", () => {
|
||||||
assert.equal(decoded.items[0].source.passiveProcessingOnly, true);
|
assert.equal(decoded.items[0].source.passiveProcessingOnly, true);
|
||||||
assert.equal(decoded.items[0].metrics.sensorHeightM.p50, 1.3);
|
assert.equal(decoded.items[0].metrics.sensorHeightM.p50, 1.3);
|
||||||
assert.equal(decoded.items[0].anchors[0].sourceFrameIndex, 1350);
|
assert.equal(decoded.items[0].anchors[0].sourceFrameIndex, 1350);
|
||||||
|
assert.equal(
|
||||||
|
decoded.items[0].metrics.temporalQualification.prediction.sampleCount,
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
|
||||||
const decodedFrame = parseLidarLocalSurfaceFrame(frame());
|
const decodedFrame = parseLidarLocalSurfaceFrame(frame());
|
||||||
assert.equal(decodedFrame.counts.occupied, 1);
|
assert.equal(decodedFrame.counts.occupied, 1);
|
||||||
|
assert.equal(decodedFrame.counts.stepCandidate, 1);
|
||||||
|
assert.equal(decodedFrame.prediction.currentFrameExcluded, true);
|
||||||
assert.equal(decodedFrame.surface.sensorHeightM, 1.3);
|
assert.equal(decodedFrame.surface.sensorHeightM, 1.3);
|
||||||
|
|
||||||
|
const decodedTimeline = parseLidarLocalSurfaceTimeline(timeline());
|
||||||
|
assert.equal(decodedTimeline.frameCount, 4);
|
||||||
|
assert.deepEqual(decodedTimeline.temporalJump, [0, 0, 1, 0]);
|
||||||
|
assert.equal(decodedTimeline.predictionResidualP50M[2], 0.05);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects inferred free space", () => {
|
test("rejects inferred free space", () => {
|
||||||
|
|
@ -210,3 +300,21 @@ test("rejects command authority", () => {
|
||||||
LidarLocalSurfaceContractError,
|
LidarLocalSurfaceContractError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fetches the complete local-surface timeline read-only", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const decoded = await fetchLidarLocalSurfaceTimeline(modelId, {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
requests.push({ input: String(input), method: init?.method });
|
||||||
|
return new Response(JSON.stringify(timeline()), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(requests, [{
|
||||||
|
input: `/api/v1/lidar/local-surfaces/${modelId}/timeline`,
|
||||||
|
method: "GET",
|
||||||
|
}]);
|
||||||
|
assert.equal(decoded.frameCount, 4);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
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; L2.6a K1 replay
|
complete; full GOOSE and RELLIS qualification complete; L2.6b K1 replay
|
||||||
local-surface slice implemented; operator review and live shadow next
|
local-surface temporal qualification 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
|
||||||
|
|
@ -398,7 +399,7 @@ Dataset expansion is no longer the next gate.
|
||||||
`1.27 m` value.
|
`1.27 m` value.
|
||||||
- [x] Publish surface-relative height, slope, roughness and confidence
|
- [x] Publish surface-relative height, slope, roughness and confidence
|
||||||
separately from semantic classes.
|
separately from semantic classes.
|
||||||
- [ ] Add independently reviewable step/curb candidates; do not derive them
|
- [x] Add independently reviewable step/curb candidates; do not derive them
|
||||||
from a single global height threshold.
|
from a single global height threshold.
|
||||||
- [x] Produce short-TTL observed-surface, `occupied` and `unknown` evidence.
|
- [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.
|
||||||
|
|
@ -408,26 +409,47 @@ Dataset expansion is no longer the next gate.
|
||||||
derivatives with independent decay and provenance.
|
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.
|
||||||
- [ ] Complete the qualification report with per-frame latency, point age,
|
- [x] Qualify next-frame prediction and temporal stability with the evaluated
|
||||||
temporal stability, obstacle preservation and memory growth.
|
frame excluded from prediction input.
|
||||||
|
- [ ] Complete the remaining qualification report with per-frame latency,
|
||||||
|
point age, 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
|
The implemented `missioncore.k1-local-surface/v1` derivative is reproducible
|
||||||
through `experiments/perception/run_k1_local_surface.py` and is exposed
|
through `experiments/perception/run_k1_local_surface.py` and is exposed
|
||||||
read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame
|
read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame
|
||||||
endpoint. **Парк → Диагностика LiDAR** reuses the five RAVNOVES00 scene
|
and timeline endpoints. **Парк → Диагностика LiDAR** reuses the five
|
||||||
selectors and shows the selected source frame as observed surface, observed
|
RAVNOVES00 scene selectors and also exposes a clickable timeline over the
|
||||||
occupied-above-surface, negative outlier and unclassified evidence. The React
|
complete recording. The selected source frame shows observed surface, observed
|
||||||
contract is provider-neutral; K1 remains a bound backend source rather than UI
|
occupied-above-surface, negative outlier, unclassified evidence and yellow
|
||||||
implementation knowledge.
|
local-discontinuity candidates. The candidates are not semantic curb
|
||||||
|
recognition or ground truth. 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.
|
The complete RAVNOVES00 run covered all `526/526` available LiDAR samples.
|
||||||
There were zero stale-pose, insufficient-surface or failed-fit frames. Observed
|
There were zero stale-pose, insufficient-surface or failed-fit frames. Observed
|
||||||
diagnostic distributions are: derived sensor-to-surface height `1.295 m` p50,
|
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`
|
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,
|
p95. These values describe this recording only; they are not calibration,
|
||||||
ground truth or a navigation gate. Free space remains unavailable.
|
ground truth or a navigation gate.
|
||||||
|
|
||||||
|
The L2.6b qualification scores each available frame against a local plane built
|
||||||
|
only from the preceding TTL window; the frame being scored is excluded from
|
||||||
|
the prediction input. It produced `525` independent next-frame samples. The
|
||||||
|
distribution of per-frame median absolute residual has `0.038543 m` p50 and
|
||||||
|
`0.050398 m` p95. Prediction inlier fraction has `0.96354` p50 and `0.95133`
|
||||||
|
mean. The temporal comparison detected `12/525` strict-threshold jumps; height
|
||||||
|
delta is `0.022887 m` p95, slope delta `0.09572°` p95 and roughness delta
|
||||||
|
`0.003317 m` p95.
|
||||||
|
|
||||||
|
The central surface is therefore repeatable at roughly four-centimetre median
|
||||||
|
error on this replay. The tail is not yet planner evidence: the distribution
|
||||||
|
of per-frame p95 residual has `0.41496 m` p95 and reaches `1.083 m`. Step/curb
|
||||||
|
candidates occur in every available frame (`68.5` cells and `83` points p50),
|
||||||
|
which proves that the review layer is active but also that it is broad and
|
||||||
|
still requires independent review. Free space remains unavailable; none of
|
||||||
|
these metrics grants navigation, command or safety authority.
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -512,8 +534,10 @@ 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 first K1 local-surface replay slice now
|
enough for the current decision. The first K1 local-surface replay slice now
|
||||||
covers all available `RAVNOVES00` samples and is visible in the operator
|
covers all available `RAVNOVES00` samples and is visible in the operator
|
||||||
interface. The highest-value immediate work is operator review, explicit
|
interface. Leave-current-frame-out temporal qualification now covers `525`
|
||||||
step/curb and temporal-stability qualification, followed by bounded live
|
samples: the median surface error is stable, while the p95 tail remains too
|
||||||
shadow. Nvblox, raw-scan detectors and alternative SLAM remain optional later
|
large for a free-space claim. The highest-value immediate work is review of the
|
||||||
gates because the current report contract does not carry their required
|
`12` temporal jumps and worst prediction tails, then a bounded live-shadow
|
||||||
ray/timing semantics.
|
queue and separate dynamic-observation layer. Nvblox, raw-scan detectors and
|
||||||
|
alternative SLAM remain optional later gates because the current report
|
||||||
|
contract does not carry their required ray/timing semantics.
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,11 @@ Implemented now:
|
||||||
- a content-addressed `missioncore.k1-local-surface/v1` replay derivative over
|
- a content-addressed `missioncore.k1-local-surface/v1` replay derivative over
|
||||||
immutable `RAVNOVES00`, with dynamic height/slope/roughness/confidence,
|
immutable `RAVNOVES00`, with dynamic height/slope/roughness/confidence,
|
||||||
explicit pose-binding age and conservative observed occupied/unknown policy;
|
explicit pose-binding age and conservative observed occupied/unknown policy;
|
||||||
|
- leave-current-frame-out next-frame qualification over `525` samples,
|
||||||
|
temporal jump evidence and unverified local-discontinuity candidates;
|
||||||
- a provider-neutral read-only local-surface view in
|
- a provider-neutral read-only local-surface view in
|
||||||
**Парк → Диагностика LiDAR**, synchronized to the five existing
|
**Парк → Диагностика LiDAR**, synchronized to the five existing
|
||||||
RAVNOVES00 scene selectors;
|
RAVNOVES00 scene selectors and a clickable complete-recording timeline;
|
||||||
- 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 **Полигон → Датасеты**;
|
||||||
|
|
@ -83,7 +85,7 @@ Not implemented:
|
||||||
- no model training or production promotion;
|
- no model training or production promotion;
|
||||||
- no RELLIS ROS bag admission, continuous synchronized playback or production
|
- no RELLIS ROS bag admission, continuous synchronized playback or production
|
||||||
promotion;
|
promotion;
|
||||||
- no rolling-map implementation yet.
|
- no ray-cleared free-space or planner-authoritative rolling occupancy map.
|
||||||
|
|
||||||
## Product surface boundary
|
## Product surface boundary
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ from .lidar_local_surface import (
|
||||||
K1_LOCAL_SURFACE_FRAME_SCHEMA,
|
K1_LOCAL_SURFACE_FRAME_SCHEMA,
|
||||||
K1_LOCAL_SURFACE_REPORT_SCHEMA,
|
K1_LOCAL_SURFACE_REPORT_SCHEMA,
|
||||||
K1_LOCAL_SURFACE_SCHEMA,
|
K1_LOCAL_SURFACE_SCHEMA,
|
||||||
|
K1_LOCAL_SURFACE_TIMELINE_SCHEMA,
|
||||||
K1LocalSurfaceProfile,
|
K1LocalSurfaceProfile,
|
||||||
K1LocalSurfaceV1,
|
K1LocalSurfaceV1,
|
||||||
build_k1_local_surface,
|
build_k1_local_surface,
|
||||||
|
|
@ -209,6 +210,7 @@ __all__ = [
|
||||||
"K1_LOCAL_SURFACE_FRAME_SCHEMA",
|
"K1_LOCAL_SURFACE_FRAME_SCHEMA",
|
||||||
"K1_LOCAL_SURFACE_REPORT_SCHEMA",
|
"K1_LOCAL_SURFACE_REPORT_SCHEMA",
|
||||||
"K1_LOCAL_SURFACE_SCHEMA",
|
"K1_LOCAL_SURFACE_SCHEMA",
|
||||||
|
"K1_LOCAL_SURFACE_TIMELINE_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",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from .lidar_field_review import (
|
||||||
K1_LOCAL_SURFACE_SCHEMA: Final = "missioncore.k1-local-surface/v1"
|
K1_LOCAL_SURFACE_SCHEMA: Final = "missioncore.k1-local-surface/v1"
|
||||||
K1_LOCAL_SURFACE_REPORT_SCHEMA: Final = "missioncore.k1-local-surface-report/v1"
|
K1_LOCAL_SURFACE_REPORT_SCHEMA: Final = "missioncore.k1-local-surface-report/v1"
|
||||||
K1_LOCAL_SURFACE_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v1"
|
K1_LOCAL_SURFACE_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v1"
|
||||||
|
K1_LOCAL_SURFACE_TIMELINE_SCHEMA: Final = "missioncore.k1-local-surface-timeline/v1"
|
||||||
K1_LOCAL_SURFACE_ARRAYS_NAME: Final = "local-surface.npz"
|
K1_LOCAL_SURFACE_ARRAYS_NAME: Final = "local-surface.npz"
|
||||||
K1_LOCAL_SURFACE_REPORT_NAME: Final = "local-surface.json"
|
K1_LOCAL_SURFACE_REPORT_NAME: Final = "local-surface.json"
|
||||||
K1_LOCAL_SURFACE_MANIFEST_NAME: Final = "manifest.json"
|
K1_LOCAL_SURFACE_MANIFEST_NAME: Final = "manifest.json"
|
||||||
|
|
@ -67,6 +68,12 @@ class K1LocalSurfaceProfile:
|
||||||
obstacle_max_height_m: float = 3.5
|
obstacle_max_height_m: float = 3.5
|
||||||
maximum_pose_binding_ms: float = 100.0
|
maximum_pose_binding_ms: float = 100.0
|
||||||
maximum_slope_deg: float = 40.0
|
maximum_slope_deg: float = 40.0
|
||||||
|
step_min_height_m: float = 0.07
|
||||||
|
step_max_height_m: float = 0.32
|
||||||
|
step_max_plane_residual_m: float = 0.45
|
||||||
|
temporal_height_jump_m: float = 0.03
|
||||||
|
temporal_slope_jump_deg: float = 0.5
|
||||||
|
temporal_roughness_jump_m: float = 0.015
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
numeric = (
|
numeric = (
|
||||||
|
|
@ -82,6 +89,12 @@ class K1LocalSurfaceProfile:
|
||||||
self.obstacle_max_height_m,
|
self.obstacle_max_height_m,
|
||||||
self.maximum_pose_binding_ms,
|
self.maximum_pose_binding_ms,
|
||||||
self.maximum_slope_deg,
|
self.maximum_slope_deg,
|
||||||
|
self.step_min_height_m,
|
||||||
|
self.step_max_height_m,
|
||||||
|
self.step_max_plane_residual_m,
|
||||||
|
self.temporal_height_jump_m,
|
||||||
|
self.temporal_slope_jump_deg,
|
||||||
|
self.temporal_roughness_jump_m,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
not self.profile_id.strip()
|
not self.profile_id.strip()
|
||||||
|
|
@ -101,6 +114,11 @@ class K1LocalSurfaceProfile:
|
||||||
or not self.obstacle_min_height_m < self.obstacle_max_height_m <= 20.0
|
or not self.obstacle_min_height_m < self.obstacle_max_height_m <= 20.0
|
||||||
or not 1.0 <= self.maximum_pose_binding_ms <= 10_000.0
|
or not 1.0 <= self.maximum_pose_binding_ms <= 10_000.0
|
||||||
or not 1.0 <= self.maximum_slope_deg < 90.0
|
or not 1.0 <= self.maximum_slope_deg < 90.0
|
||||||
|
or not 0.02 <= self.step_min_height_m < self.step_max_height_m
|
||||||
|
or not self.step_max_height_m <= self.step_max_plane_residual_m <= 2.0
|
||||||
|
or not 0.02 <= self.temporal_height_jump_m <= 2.0
|
||||||
|
or not 0.1 <= self.temporal_slope_jump_deg <= 45.0
|
||||||
|
or not 0.005 <= self.temporal_roughness_jump_m <= 1.0
|
||||||
):
|
):
|
||||||
raise LidarGroundError("K1 local-surface profile is invalid")
|
raise LidarGroundError("K1 local-surface profile is invalid")
|
||||||
|
|
||||||
|
|
@ -125,6 +143,9 @@ class K1LocalSurfaceProfile:
|
||||||
"robust_mad_scale": self.robust_mad_scale,
|
"robust_mad_scale": self.robust_mad_scale,
|
||||||
"minimum_inlier_band_m": self.minimum_inlier_band_m,
|
"minimum_inlier_band_m": self.minimum_inlier_band_m,
|
||||||
"maximum_slope_deg": self.maximum_slope_deg,
|
"maximum_slope_deg": self.maximum_slope_deg,
|
||||||
|
"step_min_height_m": self.step_min_height_m,
|
||||||
|
"step_max_height_m": self.step_max_height_m,
|
||||||
|
"step_max_plane_residual_m": self.step_max_plane_residual_m,
|
||||||
},
|
},
|
||||||
"classification": {
|
"classification": {
|
||||||
"surface_band_m": self.surface_band_m,
|
"surface_band_m": self.surface_band_m,
|
||||||
|
|
@ -137,6 +158,13 @@ class K1LocalSurfaceProfile:
|
||||||
"basis": "recorded-nearest-host-monotonic-arrival",
|
"basis": "recorded-nearest-host-monotonic-arrival",
|
||||||
"maximum_age_ms": self.maximum_pose_binding_ms,
|
"maximum_age_ms": self.maximum_pose_binding_ms,
|
||||||
},
|
},
|
||||||
|
"temporal_qualification": {
|
||||||
|
"prediction_input": "previous-ttl-window-only",
|
||||||
|
"current_frame_excluded_from_prediction": True,
|
||||||
|
"height_jump_m": self.temporal_height_jump_m,
|
||||||
|
"slope_jump_deg": self.temporal_slope_jump_deg,
|
||||||
|
"roughness_jump_m": self.temporal_roughness_jump_m,
|
||||||
|
},
|
||||||
"authority": {
|
"authority": {
|
||||||
"commands_enabled": False,
|
"commands_enabled": False,
|
||||||
"navigation_or_safety_accepted": False,
|
"navigation_or_safety_accepted": False,
|
||||||
|
|
@ -186,7 +214,7 @@ class K1LocalSurfaceV1:
|
||||||
def _validate(self) -> None:
|
def _validate(self) -> None:
|
||||||
frame_count = _nonnegative_int(self.identity.get("frame_count"), "frame count")
|
frame_count = _nonnegative_int(self.identity.get("frame_count"), "frame count")
|
||||||
point_count = _nonnegative_int(self.identity.get("point_count"), "point count")
|
point_count = _nonnegative_int(self.identity.get("point_count"), "point count")
|
||||||
required = {
|
baseline = {
|
||||||
"frame_valid",
|
"frame_valid",
|
||||||
"frame_failure_code",
|
"frame_failure_code",
|
||||||
"plane_coefficients_map",
|
"plane_coefficients_map",
|
||||||
|
|
@ -205,8 +233,25 @@ class K1LocalSurfaceV1:
|
||||||
"point_class",
|
"point_class",
|
||||||
"point_height_m",
|
"point_height_m",
|
||||||
}
|
}
|
||||||
if set(self.arrays.files) != required:
|
qualification = {
|
||||||
|
"prediction_available",
|
||||||
|
"prediction_cell_count",
|
||||||
|
"prediction_residual_p50_m",
|
||||||
|
"prediction_residual_p95_m",
|
||||||
|
"prediction_inlier_fraction",
|
||||||
|
"height_delta_m",
|
||||||
|
"slope_delta_deg",
|
||||||
|
"roughness_delta_m",
|
||||||
|
"temporal_compared",
|
||||||
|
"temporal_jump",
|
||||||
|
"step_candidate_cell_count",
|
||||||
|
"step_candidate_point_count",
|
||||||
|
"point_step_candidate",
|
||||||
|
}
|
||||||
|
files = set(self.arrays.files)
|
||||||
|
if files not in (baseline, baseline | qualification):
|
||||||
raise LidarGroundError("K1 local-surface arrays are incomplete")
|
raise LidarGroundError("K1 local-surface arrays are incomplete")
|
||||||
|
self.has_temporal_qualification = qualification <= files
|
||||||
vector_f64 = (
|
vector_f64 = (
|
||||||
"sensor_height_m",
|
"sensor_height_m",
|
||||||
"slope_deg",
|
"slope_deg",
|
||||||
|
|
@ -261,6 +306,63 @@ class K1LocalSurfaceV1:
|
||||||
or np.any(value < 0)
|
or np.any(value < 0)
|
||||||
):
|
):
|
||||||
raise LidarGroundError(f"K1 local-surface {name} is invalid")
|
raise LidarGroundError(f"K1 local-surface {name} is invalid")
|
||||||
|
if self.has_temporal_qualification:
|
||||||
|
qualification_f64 = (
|
||||||
|
"prediction_residual_p50_m",
|
||||||
|
"prediction_residual_p95_m",
|
||||||
|
"prediction_inlier_fraction",
|
||||||
|
"height_delta_m",
|
||||||
|
"slope_delta_deg",
|
||||||
|
"roughness_delta_m",
|
||||||
|
)
|
||||||
|
qualification_i64 = (
|
||||||
|
"prediction_cell_count",
|
||||||
|
"step_candidate_cell_count",
|
||||||
|
"step_candidate_point_count",
|
||||||
|
)
|
||||||
|
for name in qualification_f64:
|
||||||
|
value = self.arrays[name]
|
||||||
|
if (
|
||||||
|
value.shape != (frame_count,)
|
||||||
|
or value.dtype != np.dtype("<f8")
|
||||||
|
or not np.isfinite(value).all()
|
||||||
|
):
|
||||||
|
raise LidarGroundError(
|
||||||
|
f"K1 local-surface qualification {name} is invalid"
|
||||||
|
)
|
||||||
|
for name in qualification_i64:
|
||||||
|
value = self.arrays[name]
|
||||||
|
if (
|
||||||
|
value.shape != (frame_count,)
|
||||||
|
or value.dtype != np.dtype("<i8")
|
||||||
|
or np.any(value < 0)
|
||||||
|
):
|
||||||
|
raise LidarGroundError(
|
||||||
|
f"K1 local-surface qualification {name} is invalid"
|
||||||
|
)
|
||||||
|
for name in (
|
||||||
|
"prediction_available",
|
||||||
|
"temporal_compared",
|
||||||
|
"temporal_jump",
|
||||||
|
):
|
||||||
|
value = self.arrays[name]
|
||||||
|
if value.shape != (frame_count,) or value.dtype != np.dtype("?"):
|
||||||
|
raise LidarGroundError(
|
||||||
|
f"K1 local-surface qualification {name} is invalid"
|
||||||
|
)
|
||||||
|
step_candidate = self.arrays["point_step_candidate"]
|
||||||
|
if (
|
||||||
|
step_candidate.shape != (point_count,)
|
||||||
|
or step_candidate.dtype != np.dtype("u1")
|
||||||
|
or np.any(step_candidate > 1)
|
||||||
|
or np.any(
|
||||||
|
self.arrays["prediction_inlier_fraction"][
|
||||||
|
self.arrays["prediction_available"]
|
||||||
|
]
|
||||||
|
> 1
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise LidarGroundError("K1 local-surface step candidates are invalid")
|
||||||
valid = self.arrays["frame_valid"]
|
valid = self.arrays["frame_valid"]
|
||||||
if (
|
if (
|
||||||
np.any(self.arrays["frame_failure_code"][valid] != FRAME_VALID)
|
np.any(self.arrays["frame_failure_code"][valid] != FRAME_VALID)
|
||||||
|
|
@ -270,6 +372,40 @@ class K1LocalSurfaceV1:
|
||||||
or self.identity.get("valid_frame_count") != int(np.count_nonzero(valid))
|
or self.identity.get("valid_frame_count") != int(np.count_nonzero(valid))
|
||||||
):
|
):
|
||||||
raise LidarGroundError("K1 local-surface frame validity is inconsistent")
|
raise LidarGroundError("K1 local-surface frame validity is inconsistent")
|
||||||
|
if self.has_temporal_qualification:
|
||||||
|
metrics = _object(
|
||||||
|
self.report.get("metrics"),
|
||||||
|
"K1 local-surface metrics",
|
||||||
|
)
|
||||||
|
qualification_report = _object(
|
||||||
|
metrics.get("temporal_qualification"),
|
||||||
|
"K1 local-surface temporal qualification",
|
||||||
|
)
|
||||||
|
prediction_report = _object(
|
||||||
|
qualification_report.get("prediction"),
|
||||||
|
"K1 local-surface prediction report",
|
||||||
|
)
|
||||||
|
stability_report = _object(
|
||||||
|
qualification_report.get("stability"),
|
||||||
|
"K1 local-surface stability report",
|
||||||
|
)
|
||||||
|
step_report = _object(
|
||||||
|
qualification_report.get("step_candidates"),
|
||||||
|
"K1 local-surface step report",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
prediction_report.get("current_frame_excluded") is not True
|
||||||
|
or prediction_report.get("sample_count")
|
||||||
|
!= int(np.count_nonzero(self.arrays["prediction_available"]))
|
||||||
|
or stability_report.get("sample_count")
|
||||||
|
!= int(np.count_nonzero(self.arrays["temporal_compared"]))
|
||||||
|
or stability_report.get("jump_count")
|
||||||
|
!= int(np.count_nonzero(self.arrays["temporal_jump"]))
|
||||||
|
or step_report.get("is_ground_truth") is not False
|
||||||
|
):
|
||||||
|
raise LidarGroundError(
|
||||||
|
"K1 local-surface temporal report is inconsistent"
|
||||||
|
)
|
||||||
authority = _object(self.report.get("authority"), "K1 local-surface authority")
|
authority = _object(self.report.get("authority"), "K1 local-surface authority")
|
||||||
policy = _object(self.report.get("occupancy_policy"), "K1 local-surface policy")
|
policy = _object(self.report.get("occupancy_policy"), "K1 local-surface policy")
|
||||||
if (
|
if (
|
||||||
|
|
@ -292,17 +428,27 @@ class K1LocalSurfaceV1:
|
||||||
start = int(offsets[frame_index])
|
start = int(offsets[frame_index])
|
||||||
end = int(offsets[frame_index + 1])
|
end = int(offsets[frame_index + 1])
|
||||||
point_class = self.arrays["point_class"][start:end]
|
point_class = self.arrays["point_class"][start:end]
|
||||||
|
if self.has_temporal_qualification:
|
||||||
|
step_candidate = self.arrays["point_step_candidate"][start:end]
|
||||||
|
else:
|
||||||
|
step_candidate = np.zeros(end - start, dtype=np.uint8)
|
||||||
counts = {
|
counts = {
|
||||||
"classified": int(np.count_nonzero(point_class)),
|
"classified": int(np.count_nonzero(point_class)),
|
||||||
"surface": int(np.count_nonzero(point_class == POINT_SURFACE)),
|
"surface": int(np.count_nonzero(point_class == POINT_SURFACE)),
|
||||||
"occupied": int(np.count_nonzero(point_class == POINT_OCCUPIED)),
|
"occupied": int(np.count_nonzero(point_class == POINT_OCCUPIED)),
|
||||||
"below_surface": int(np.count_nonzero(point_class == POINT_BELOW_SURFACE)),
|
"below_surface": int(np.count_nonzero(point_class == POINT_BELOW_SURFACE)),
|
||||||
|
"step_candidate": int(np.count_nonzero(step_candidate)),
|
||||||
}
|
}
|
||||||
expected = {
|
expected = {
|
||||||
"classified": int(self.arrays["classified_point_count"][frame_index]),
|
"classified": int(self.arrays["classified_point_count"][frame_index]),
|
||||||
"surface": int(self.arrays["surface_point_count"][frame_index]),
|
"surface": int(self.arrays["surface_point_count"][frame_index]),
|
||||||
"occupied": int(self.arrays["occupied_point_count"][frame_index]),
|
"occupied": int(self.arrays["occupied_point_count"][frame_index]),
|
||||||
"below_surface": int(self.arrays["below_surface_point_count"][frame_index]),
|
"below_surface": int(self.arrays["below_surface_point_count"][frame_index]),
|
||||||
|
"step_candidate": (
|
||||||
|
int(self.arrays["step_candidate_point_count"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if counts != expected:
|
if counts != expected:
|
||||||
raise LidarGroundError("K1 local-surface frame counts are inconsistent")
|
raise LidarGroundError("K1 local-surface frame counts are inconsistent")
|
||||||
|
|
@ -328,6 +474,7 @@ class K1LocalSurfaceV1:
|
||||||
"point_height_m": self.arrays["point_height_m"][start:end]
|
"point_height_m": self.arrays["point_height_m"][start:end]
|
||||||
.astype(np.float64)
|
.astype(np.float64)
|
||||||
.tolist(),
|
.tolist(),
|
||||||
|
"point_step_candidate": step_candidate.astype(np.int64).tolist(),
|
||||||
"pose": {
|
"pose": {
|
||||||
"position_xyz_m": source.arrays["pose_positions_map"][frame_index]
|
"position_xyz_m": source.arrays["pose_positions_map"][frame_index]
|
||||||
.astype(np.float64)
|
.astype(np.float64)
|
||||||
|
|
@ -356,6 +503,66 @@ class K1LocalSurfaceV1:
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
|
"prediction": {
|
||||||
|
"available": (
|
||||||
|
bool(self.arrays["prediction_available"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else False
|
||||||
|
),
|
||||||
|
"current_frame_excluded": True,
|
||||||
|
"cell_count": (
|
||||||
|
int(self.arrays["prediction_cell_count"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"residual_p50_m": (
|
||||||
|
float(self.arrays["prediction_residual_p50_m"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
"residual_p95_m": (
|
||||||
|
float(self.arrays["prediction_residual_p95_m"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
"inlier_fraction": (
|
||||||
|
float(self.arrays["prediction_inlier_fraction"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"temporal": {
|
||||||
|
"compared": (
|
||||||
|
bool(self.arrays["temporal_compared"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else False
|
||||||
|
),
|
||||||
|
"height_delta_m": (
|
||||||
|
float(self.arrays["height_delta_m"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
"slope_delta_deg": (
|
||||||
|
float(self.arrays["slope_delta_deg"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
"roughness_delta_m": (
|
||||||
|
float(self.arrays["roughness_delta_m"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0.0
|
||||||
|
),
|
||||||
|
"jump": (
|
||||||
|
bool(self.arrays["temporal_jump"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else False
|
||||||
|
),
|
||||||
|
"step_candidate_cell_count": (
|
||||||
|
int(self.arrays["step_candidate_cell_count"][frame_index])
|
||||||
|
if self.has_temporal_qualification
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
},
|
||||||
"classes": {
|
"classes": {
|
||||||
"0": "unclassified-or-outside-local-radius",
|
"0": "unclassified-or-outside-local-radius",
|
||||||
"1": "observed-surface",
|
"1": "observed-surface",
|
||||||
|
|
@ -368,6 +575,62 @@ class K1LocalSurfaceV1:
|
||||||
"authority": self.report["authority"],
|
"authority": self.report["authority"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def timeline_detail(self, source: E10LidarFieldSource) -> dict[str, object]:
|
||||||
|
_validate_source_binding(self, source)
|
||||||
|
frame_count = source.frame_count
|
||||||
|
if self.has_temporal_qualification:
|
||||||
|
prediction_available = self.arrays["prediction_available"]
|
||||||
|
temporal_compared = self.arrays["temporal_compared"]
|
||||||
|
temporal_jump = self.arrays["temporal_jump"]
|
||||||
|
prediction_p50 = self.arrays["prediction_residual_p50_m"]
|
||||||
|
prediction_p95 = self.arrays["prediction_residual_p95_m"]
|
||||||
|
prediction_inlier = self.arrays["prediction_inlier_fraction"]
|
||||||
|
height_delta = self.arrays["height_delta_m"]
|
||||||
|
slope_delta = self.arrays["slope_delta_deg"]
|
||||||
|
roughness_delta = self.arrays["roughness_delta_m"]
|
||||||
|
step_points = self.arrays["step_candidate_point_count"]
|
||||||
|
else:
|
||||||
|
prediction_available = np.zeros(frame_count, dtype=np.bool_)
|
||||||
|
temporal_compared = np.zeros(frame_count, dtype=np.bool_)
|
||||||
|
temporal_jump = np.zeros(frame_count, dtype=np.bool_)
|
||||||
|
prediction_p50 = np.zeros(frame_count, dtype=np.float64)
|
||||||
|
prediction_p95 = np.zeros(frame_count, dtype=np.float64)
|
||||||
|
prediction_inlier = np.zeros(frame_count, dtype=np.float64)
|
||||||
|
height_delta = np.zeros(frame_count, dtype=np.float64)
|
||||||
|
slope_delta = np.zeros(frame_count, dtype=np.float64)
|
||||||
|
roughness_delta = np.zeros(frame_count, dtype=np.float64)
|
||||||
|
step_points = np.zeros(frame_count, dtype=np.int64)
|
||||||
|
return {
|
||||||
|
"schema_version": K1_LOCAL_SURFACE_TIMELINE_SCHEMA,
|
||||||
|
"model_id": self.model_id,
|
||||||
|
"source_pack_id": source.pack_id,
|
||||||
|
"session_id": source.identity["session_id"],
|
||||||
|
"frame_count": frame_count,
|
||||||
|
"source_frame_index": source.arrays["source_frame_indices"]
|
||||||
|
.astype(np.int64)
|
||||||
|
.tolist(),
|
||||||
|
"session_seconds": source.arrays["session_seconds"].astype(np.float64).tolist(),
|
||||||
|
"source_available": source.arrays["sample_available"].astype(np.int64).tolist(),
|
||||||
|
"valid": self.arrays["frame_valid"].astype(np.int64).tolist(),
|
||||||
|
"prediction_available": prediction_available.astype(np.int64).tolist(),
|
||||||
|
"prediction_residual_p50_m": prediction_p50.astype(np.float64).tolist(),
|
||||||
|
"prediction_residual_p95_m": prediction_p95.astype(np.float64).tolist(),
|
||||||
|
"prediction_inlier_fraction": prediction_inlier.astype(np.float64).tolist(),
|
||||||
|
"sensor_height_m": self.arrays["sensor_height_m"].astype(np.float64).tolist(),
|
||||||
|
"slope_deg": self.arrays["slope_deg"].astype(np.float64).tolist(),
|
||||||
|
"roughness_m": self.arrays["roughness_m"].astype(np.float64).tolist(),
|
||||||
|
"confidence": self.arrays["confidence"].astype(np.float64).tolist(),
|
||||||
|
"temporal_compared": temporal_compared.astype(np.int64).tolist(),
|
||||||
|
"height_delta_m": height_delta.astype(np.float64).tolist(),
|
||||||
|
"slope_delta_deg": slope_delta.astype(np.float64).tolist(),
|
||||||
|
"roughness_delta_m": roughness_delta.astype(np.float64).tolist(),
|
||||||
|
"temporal_jump": temporal_jump.astype(np.int64).tolist(),
|
||||||
|
"step_candidate_point_count": step_points.astype(np.int64).tolist(),
|
||||||
|
"ground_truth": False,
|
||||||
|
"access": "read-only",
|
||||||
|
"authority": self.report["authority"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_k1_local_surface(
|
def build_k1_local_surface(
|
||||||
source: E10LidarFieldSource,
|
source: E10LidarFieldSource,
|
||||||
|
|
@ -392,6 +655,7 @@ def build_k1_local_surface(
|
||||||
times = source_arrays["session_seconds"]
|
times = source_arrays["session_seconds"]
|
||||||
pose_delta = np.abs(source_arrays["pose_point_delta_ms"])
|
pose_delta = np.abs(source_arrays["pose_point_delta_ms"])
|
||||||
cache: dict[tuple[int, int], tuple[float, float]] = {}
|
cache: dict[tuple[int, int], tuple[float, float]] = {}
|
||||||
|
previous_surface: tuple[float, float, float, float] | None = None
|
||||||
|
|
||||||
for frame_index in range(frame_count):
|
for frame_index in range(frame_count):
|
||||||
start = int(offsets[frame_index])
|
start = int(offsets[frame_index])
|
||||||
|
|
@ -416,8 +680,27 @@ def build_k1_local_surface(
|
||||||
)
|
)
|
||||||
local_cloud = cloud[local]
|
local_cloud = cloud[local]
|
||||||
_expire_cache(cache, session_seconds, position, profile)
|
_expire_cache(cache, session_seconds, position, profile)
|
||||||
|
_, prior_cell_points, _ = _local_cache_records(cache, position, profile)
|
||||||
|
_, current_cell_points = _cloud_cell_observations(local_cloud, profile)
|
||||||
|
prediction = _prediction_metrics(
|
||||||
|
prior_cell_points,
|
||||||
|
current_cell_points,
|
||||||
|
position,
|
||||||
|
profile,
|
||||||
|
)
|
||||||
|
if prediction is not None:
|
||||||
|
residual_p50, residual_p95, inlier_fraction, prediction_cells = prediction
|
||||||
|
arrays["prediction_available"][frame_index] = True
|
||||||
|
arrays["prediction_cell_count"][frame_index] = prediction_cells
|
||||||
|
arrays["prediction_residual_p50_m"][frame_index] = residual_p50
|
||||||
|
arrays["prediction_residual_p95_m"][frame_index] = residual_p95
|
||||||
|
arrays["prediction_inlier_fraction"][frame_index] = inlier_fraction
|
||||||
_update_cache(cache, local_cloud, session_seconds, profile)
|
_update_cache(cache, local_cloud, session_seconds, profile)
|
||||||
cell_points, cell_times = _local_cache_points(cache, position, profile)
|
cell_keys, cell_points, cell_times = _local_cache_records(
|
||||||
|
cache,
|
||||||
|
position,
|
||||||
|
profile,
|
||||||
|
)
|
||||||
arrays["surface_cell_count"][frame_index] = cell_points.shape[0]
|
arrays["surface_cell_count"][frame_index] = cell_points.shape[0]
|
||||||
if cell_points.shape[0] < profile.minimum_surface_cells:
|
if cell_points.shape[0] < profile.minimum_surface_cells:
|
||||||
arrays["frame_failure_code"][frame_index] = FRAME_INSUFFICIENT_SURFACE
|
arrays["frame_failure_code"][frame_index] = FRAME_INSUFFICIENT_SURFACE
|
||||||
|
|
@ -442,6 +725,14 @@ def build_k1_local_surface(
|
||||||
& (heights <= profile.obstacle_max_height_m)
|
& (heights <= profile.obstacle_max_height_m)
|
||||||
] = POINT_OCCUPIED
|
] = POINT_OCCUPIED
|
||||||
local_classes[local & (heights < -profile.surface_band_m)] = POINT_BELOW_SURFACE
|
local_classes[local & (heights < -profile.surface_band_m)] = POINT_BELOW_SURFACE
|
||||||
|
step_keys = _step_candidate_keys(cell_keys, cell_points, plane, profile)
|
||||||
|
step_candidate = _point_step_candidates(
|
||||||
|
cloud,
|
||||||
|
local,
|
||||||
|
heights,
|
||||||
|
step_keys,
|
||||||
|
profile,
|
||||||
|
)
|
||||||
point_heights = np.zeros(cloud.shape[0], dtype=np.float32)
|
point_heights = np.zeros(cloud.shape[0], dtype=np.float32)
|
||||||
point_heights[local] = heights[local].astype(np.float32)
|
point_heights[local] = heights[local].astype(np.float32)
|
||||||
sensor_height = float(_height_above_plane(position.reshape(1, 3), plane)[0])
|
sensor_height = float(_height_above_plane(position.reshape(1, 3), plane)[0])
|
||||||
|
|
@ -453,6 +744,23 @@ def build_k1_local_surface(
|
||||||
1.0 - float(pose_delta[frame_index]) / profile.maximum_pose_binding_ms,
|
1.0 - float(pose_delta[frame_index]) / profile.maximum_pose_binding_ms,
|
||||||
)
|
)
|
||||||
confidence = float(np.clip(coverage * roughness_confidence * pose_confidence, 0.0, 1.0))
|
confidence = float(np.clip(coverage * roughness_confidence * pose_confidence, 0.0, 1.0))
|
||||||
|
if (
|
||||||
|
previous_surface is not None
|
||||||
|
and session_seconds - previous_surface[0] <= profile.surface_ttl_s
|
||||||
|
):
|
||||||
|
height_delta = abs(sensor_height - previous_surface[1])
|
||||||
|
slope_delta = abs(slope_deg - previous_surface[2])
|
||||||
|
roughness_delta = abs(roughness - previous_surface[3])
|
||||||
|
arrays["height_delta_m"][frame_index] = height_delta
|
||||||
|
arrays["slope_delta_deg"][frame_index] = slope_delta
|
||||||
|
arrays["roughness_delta_m"][frame_index] = roughness_delta
|
||||||
|
arrays["temporal_compared"][frame_index] = True
|
||||||
|
arrays["temporal_jump"][frame_index] = (
|
||||||
|
height_delta > profile.temporal_height_jump_m
|
||||||
|
or slope_delta > profile.temporal_slope_jump_deg
|
||||||
|
or roughness_delta > profile.temporal_roughness_jump_m
|
||||||
|
)
|
||||||
|
previous_surface = (session_seconds, sensor_height, slope_deg, roughness)
|
||||||
arrays["frame_valid"][frame_index] = True
|
arrays["frame_valid"][frame_index] = True
|
||||||
arrays["frame_failure_code"][frame_index] = FRAME_VALID
|
arrays["frame_failure_code"][frame_index] = FRAME_VALID
|
||||||
arrays["plane_coefficients_map"][frame_index] = plane
|
arrays["plane_coefficients_map"][frame_index] = plane
|
||||||
|
|
@ -467,6 +775,7 @@ def build_k1_local_surface(
|
||||||
arrays["surface_inlier_cell_count"][frame_index] = int(np.count_nonzero(inliers))
|
arrays["surface_inlier_cell_count"][frame_index] = int(np.count_nonzero(inliers))
|
||||||
arrays["point_class"][start:end] = local_classes
|
arrays["point_class"][start:end] = local_classes
|
||||||
arrays["point_height_m"][start:end] = point_heights
|
arrays["point_height_m"][start:end] = point_heights
|
||||||
|
arrays["point_step_candidate"][start:end] = step_candidate
|
||||||
arrays["classified_point_count"][frame_index] = int(
|
arrays["classified_point_count"][frame_index] = int(
|
||||||
np.count_nonzero(local_classes)
|
np.count_nonzero(local_classes)
|
||||||
)
|
)
|
||||||
|
|
@ -479,6 +788,10 @@ def build_k1_local_surface(
|
||||||
arrays["below_surface_point_count"][frame_index] = int(
|
arrays["below_surface_point_count"][frame_index] = int(
|
||||||
np.count_nonzero(local_classes == POINT_BELOW_SURFACE)
|
np.count_nonzero(local_classes == POINT_BELOW_SURFACE)
|
||||||
)
|
)
|
||||||
|
arrays["step_candidate_cell_count"][frame_index] = len(step_keys)
|
||||||
|
arrays["step_candidate_point_count"][frame_index] = int(
|
||||||
|
np.count_nonzero(step_candidate)
|
||||||
|
)
|
||||||
|
|
||||||
logical_content_sha256 = _logical_sha256(arrays)
|
logical_content_sha256 = _logical_sha256(arrays)
|
||||||
valid = arrays["frame_valid"]
|
valid = arrays["frame_valid"]
|
||||||
|
|
@ -579,6 +892,58 @@ def build_k1_local_surface(
|
||||||
"surface_max_age_ms": _valid_distribution(
|
"surface_max_age_ms": _valid_distribution(
|
||||||
arrays["surface_max_age_ms"], valid
|
arrays["surface_max_age_ms"], valid
|
||||||
),
|
),
|
||||||
|
"temporal_qualification": {
|
||||||
|
"prediction": {
|
||||||
|
"current_frame_excluded": True,
|
||||||
|
"sample_count": int(
|
||||||
|
np.count_nonzero(arrays["prediction_available"])
|
||||||
|
),
|
||||||
|
"residual_p50_m": _valid_distribution(
|
||||||
|
arrays["prediction_residual_p50_m"],
|
||||||
|
arrays["prediction_available"],
|
||||||
|
),
|
||||||
|
"residual_p95_m": _valid_distribution(
|
||||||
|
arrays["prediction_residual_p95_m"],
|
||||||
|
arrays["prediction_available"],
|
||||||
|
),
|
||||||
|
"inlier_fraction": _valid_distribution(
|
||||||
|
arrays["prediction_inlier_fraction"],
|
||||||
|
arrays["prediction_available"],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"stability": {
|
||||||
|
"sample_count": int(
|
||||||
|
np.count_nonzero(arrays["temporal_compared"])
|
||||||
|
),
|
||||||
|
"height_delta_m": _valid_distribution(
|
||||||
|
arrays["height_delta_m"],
|
||||||
|
arrays["temporal_compared"],
|
||||||
|
),
|
||||||
|
"slope_delta_deg": _valid_distribution(
|
||||||
|
arrays["slope_delta_deg"],
|
||||||
|
arrays["temporal_compared"],
|
||||||
|
),
|
||||||
|
"roughness_delta_m": _valid_distribution(
|
||||||
|
arrays["roughness_delta_m"],
|
||||||
|
arrays["temporal_compared"],
|
||||||
|
),
|
||||||
|
"jump_count": int(np.count_nonzero(arrays["temporal_jump"])),
|
||||||
|
},
|
||||||
|
"step_candidates": {
|
||||||
|
"is_ground_truth": False,
|
||||||
|
"frames_with_candidates": int(
|
||||||
|
np.count_nonzero(arrays["step_candidate_cell_count"] > 0)
|
||||||
|
),
|
||||||
|
"cell_count": _valid_distribution(
|
||||||
|
arrays["step_candidate_cell_count"].astype(np.float64),
|
||||||
|
valid,
|
||||||
|
),
|
||||||
|
"point_count": _valid_distribution(
|
||||||
|
arrays["step_candidate_point_count"].astype(np.float64),
|
||||||
|
valid,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
"build_elapsed_ms": (time.perf_counter() - started) * 1_000.0,
|
"build_elapsed_ms": (time.perf_counter() - started) * 1_000.0,
|
||||||
},
|
},
|
||||||
"anchors": _anchors(source, valid),
|
"anchors": _anchors(source, valid),
|
||||||
|
|
@ -666,8 +1031,21 @@ def _empty_arrays(
|
||||||
"surface_point_count": np.zeros(frame_count, dtype="<i8"),
|
"surface_point_count": np.zeros(frame_count, dtype="<i8"),
|
||||||
"occupied_point_count": np.zeros(frame_count, dtype="<i8"),
|
"occupied_point_count": np.zeros(frame_count, dtype="<i8"),
|
||||||
"below_surface_point_count": np.zeros(frame_count, dtype="<i8"),
|
"below_surface_point_count": np.zeros(frame_count, dtype="<i8"),
|
||||||
|
"prediction_available": np.zeros(frame_count, dtype="?"),
|
||||||
|
"prediction_cell_count": np.zeros(frame_count, dtype="<i8"),
|
||||||
|
"prediction_residual_p50_m": np.zeros(frame_count, dtype="<f8"),
|
||||||
|
"prediction_residual_p95_m": np.zeros(frame_count, dtype="<f8"),
|
||||||
|
"prediction_inlier_fraction": np.zeros(frame_count, dtype="<f8"),
|
||||||
|
"height_delta_m": np.zeros(frame_count, dtype="<f8"),
|
||||||
|
"slope_delta_deg": np.zeros(frame_count, dtype="<f8"),
|
||||||
|
"roughness_delta_m": np.zeros(frame_count, dtype="<f8"),
|
||||||
|
"temporal_compared": np.zeros(frame_count, dtype="?"),
|
||||||
|
"temporal_jump": np.zeros(frame_count, dtype="?"),
|
||||||
|
"step_candidate_cell_count": np.zeros(frame_count, dtype="<i8"),
|
||||||
|
"step_candidate_point_count": np.zeros(frame_count, dtype="<i8"),
|
||||||
"point_class": np.zeros(point_count, dtype="u1"),
|
"point_class": np.zeros(point_count, dtype="u1"),
|
||||||
"point_height_m": np.zeros(point_count, dtype="<f4"),
|
"point_height_m": np.zeros(point_count, dtype="<f4"),
|
||||||
|
"point_step_candidate": np.zeros(point_count, dtype="u1"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -677,8 +1055,17 @@ def _update_cache(
|
||||||
session_seconds: float,
|
session_seconds: float,
|
||||||
profile: K1LocalSurfaceProfile,
|
profile: K1LocalSurfaceProfile,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
keys, points = _cloud_cell_observations(cloud, profile)
|
||||||
|
for key, point in zip(keys, points, strict=True):
|
||||||
|
cache[(int(key[0]), int(key[1]))] = (float(point[2]), session_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_cell_observations(
|
||||||
|
cloud: npt.NDArray[np.float64],
|
||||||
|
profile: K1LocalSurfaceProfile,
|
||||||
|
) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float64]]:
|
||||||
if cloud.shape[0] == 0:
|
if cloud.shape[0] == 0:
|
||||||
return
|
return np.empty((0, 2), dtype=np.int64), np.empty((0, 3), dtype=np.float64)
|
||||||
cells = np.floor(cloud[:, :2] / profile.cell_size_m).astype(np.int64)
|
cells = np.floor(cloud[:, :2] / profile.cell_size_m).astype(np.int64)
|
||||||
order = np.lexsort((cells[:, 1], cells[:, 0]))
|
order = np.lexsort((cells[:, 1], cells[:, 0]))
|
||||||
sorted_cells = cells[order]
|
sorted_cells = cells[order]
|
||||||
|
|
@ -686,10 +1073,17 @@ def _update_cache(
|
||||||
changes = np.flatnonzero(np.any(np.diff(sorted_cells, axis=0) != 0, axis=1)) + 1
|
changes = np.flatnonzero(np.any(np.diff(sorted_cells, axis=0) != 0, axis=1)) + 1
|
||||||
starts = np.concatenate((np.asarray([0]), changes))
|
starts = np.concatenate((np.asarray([0]), changes))
|
||||||
ends = np.concatenate((changes, np.asarray([cloud.shape[0]])))
|
ends = np.concatenate((changes, np.asarray([cloud.shape[0]])))
|
||||||
for start, end in zip(starts, ends, strict=True):
|
keys = np.empty((starts.shape[0], 2), dtype=np.int64)
|
||||||
key = (int(sorted_cells[start, 0]), int(sorted_cells[start, 1]))
|
points = np.empty((starts.shape[0], 3), dtype=np.float64)
|
||||||
z = float(np.percentile(sorted_z[start:end], profile.cell_lower_percentile))
|
half_cell = profile.cell_size_m * 0.5
|
||||||
cache[key] = (z, session_seconds)
|
for index, (start, end) in enumerate(zip(starts, ends, strict=True)):
|
||||||
|
keys[index] = sorted_cells[start]
|
||||||
|
points[index] = (
|
||||||
|
float(sorted_cells[start, 0]) * profile.cell_size_m + half_cell,
|
||||||
|
float(sorted_cells[start, 1]) * profile.cell_size_m + half_cell,
|
||||||
|
float(np.percentile(sorted_z[start:end], profile.cell_lower_percentile)),
|
||||||
|
)
|
||||||
|
return keys, points
|
||||||
|
|
||||||
|
|
||||||
def _expire_cache(
|
def _expire_cache(
|
||||||
|
|
@ -717,11 +1111,15 @@ def _expire_cache(
|
||||||
del cache[key]
|
del cache[key]
|
||||||
|
|
||||||
|
|
||||||
def _local_cache_points(
|
def _local_cache_records(
|
||||||
cache: Mapping[tuple[int, int], tuple[float, float]],
|
cache: Mapping[tuple[int, int], tuple[float, float]],
|
||||||
position: npt.NDArray[np.float64],
|
position: npt.NDArray[np.float64],
|
||||||
profile: K1LocalSurfaceProfile,
|
profile: K1LocalSurfaceProfile,
|
||||||
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
|
) -> tuple[
|
||||||
|
npt.NDArray[np.int64],
|
||||||
|
npt.NDArray[np.float64],
|
||||||
|
npt.NDArray[np.float64],
|
||||||
|
]:
|
||||||
values = [
|
values = [
|
||||||
(
|
(
|
||||||
(key[0] + 0.5) * profile.cell_size_m,
|
(key[0] + 0.5) * profile.cell_size_m,
|
||||||
|
|
@ -737,9 +1135,14 @@ def _local_cache_points(
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if not values:
|
if not values:
|
||||||
return np.empty((0, 3), dtype=np.float64), np.empty(0, dtype=np.float64)
|
return (
|
||||||
|
np.empty((0, 2), dtype=np.int64),
|
||||||
|
np.empty((0, 3), dtype=np.float64),
|
||||||
|
np.empty(0, dtype=np.float64),
|
||||||
|
)
|
||||||
array = np.asarray(values, dtype=np.float64)
|
array = np.asarray(values, dtype=np.float64)
|
||||||
return array[:, :3], array[:, 3]
|
keys = np.floor(array[:, :2] / profile.cell_size_m).astype(np.int64)
|
||||||
|
return keys, array[:, :3], array[:, 3]
|
||||||
|
|
||||||
|
|
||||||
def _fit_surface(
|
def _fit_surface(
|
||||||
|
|
@ -809,6 +1212,92 @@ def _fit_surface(
|
||||||
return plane.astype("<f8"), inliers, residuals
|
return plane.astype("<f8"), inliers, residuals
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_metrics(
|
||||||
|
prior_cell_points: npt.NDArray[np.float64],
|
||||||
|
current_cell_points: npt.NDArray[np.float64],
|
||||||
|
position: npt.NDArray[np.float64],
|
||||||
|
profile: K1LocalSurfaceProfile,
|
||||||
|
) -> tuple[float, float, float, int] | None:
|
||||||
|
"""Score current lower-cell evidence against a plane built without that frame."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
prior_cell_points.shape[0] < profile.minimum_surface_cells
|
||||||
|
or current_cell_points.shape[0] < profile.minimum_surface_cells
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
prior_fit = _fit_surface(prior_cell_points, position, profile)
|
||||||
|
if prior_fit is None:
|
||||||
|
return None
|
||||||
|
prior_plane, _, _ = prior_fit
|
||||||
|
cutoff = float(
|
||||||
|
np.quantile(current_cell_points[:, 2], profile.initial_lower_fraction)
|
||||||
|
)
|
||||||
|
evaluation = current_cell_points[:, 2] <= cutoff
|
||||||
|
if int(np.count_nonzero(evaluation)) < profile.minimum_surface_cells:
|
||||||
|
return None
|
||||||
|
residual = np.abs(
|
||||||
|
_height_above_plane(current_cell_points[evaluation], prior_plane)
|
||||||
|
)
|
||||||
|
if residual.size == 0 or not np.isfinite(residual).all():
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
float(np.percentile(residual, 50)),
|
||||||
|
float(np.percentile(residual, 95)),
|
||||||
|
float(np.mean(residual <= profile.surface_band_m)),
|
||||||
|
int(residual.shape[0]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _step_candidate_keys(
|
||||||
|
cell_keys: npt.NDArray[np.int64],
|
||||||
|
cell_points: npt.NDArray[np.float64],
|
||||||
|
plane: npt.NDArray[np.float64],
|
||||||
|
profile: K1LocalSurfaceProfile,
|
||||||
|
) -> set[tuple[int, int]]:
|
||||||
|
"""Find local discontinuities; the result remains an unverified candidate."""
|
||||||
|
|
||||||
|
if cell_keys.shape[0] != cell_points.shape[0]:
|
||||||
|
raise LidarGroundError("K1 local-surface cell alignment is invalid")
|
||||||
|
residual = _height_above_plane(cell_points, plane)
|
||||||
|
lookup = {
|
||||||
|
(int(key[0]), int(key[1])): float(value)
|
||||||
|
for key, value in zip(cell_keys, residual, strict=True)
|
||||||
|
if abs(float(value)) <= profile.step_max_plane_residual_m
|
||||||
|
}
|
||||||
|
candidates: set[tuple[int, int]] = set()
|
||||||
|
for key, value in lookup.items():
|
||||||
|
for neighbor in ((key[0] + 1, key[1]), (key[0], key[1] + 1)):
|
||||||
|
neighbor_value = lookup.get(neighbor)
|
||||||
|
if neighbor_value is None:
|
||||||
|
continue
|
||||||
|
delta = abs(value - neighbor_value)
|
||||||
|
if profile.step_min_height_m <= delta <= profile.step_max_height_m:
|
||||||
|
candidates.add(key)
|
||||||
|
candidates.add(neighbor)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _point_step_candidates(
|
||||||
|
cloud: npt.NDArray[np.float64],
|
||||||
|
local: npt.NDArray[np.bool_],
|
||||||
|
heights: npt.NDArray[np.float64],
|
||||||
|
candidate_keys: set[tuple[int, int]],
|
||||||
|
profile: K1LocalSurfaceProfile,
|
||||||
|
) -> npt.NDArray[np.uint8]:
|
||||||
|
result = np.zeros(cloud.shape[0], dtype=np.uint8)
|
||||||
|
if not candidate_keys:
|
||||||
|
return result
|
||||||
|
cells = np.floor(cloud[:, :2] / profile.cell_size_m).astype(np.int64)
|
||||||
|
for index in np.flatnonzero(local):
|
||||||
|
key = (int(cells[index, 0]), int(cells[index, 1]))
|
||||||
|
if (
|
||||||
|
key in candidate_keys
|
||||||
|
and abs(float(heights[index])) <= profile.step_max_plane_residual_m
|
||||||
|
):
|
||||||
|
result[index] = 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _height_above_plane(
|
def _height_above_plane(
|
||||||
points: npt.NDArray[np.float64],
|
points: npt.NDArray[np.float64],
|
||||||
plane: npt.NDArray[np.float64],
|
plane: npt.NDArray[np.float64],
|
||||||
|
|
|
||||||
|
|
@ -545,6 +545,61 @@ def build_lidar_router(
|
||||||
"access": "read-only",
|
"access": "read-only",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/local-surfaces/{model_id}/timeline")
|
||||||
|
def get_k1_local_surface_timeline(model_id: str) -> dict[str, object]:
|
||||||
|
if _LOCAL_SURFACE_ID.fullmatch(model_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="K1 local-surface timeline не найден",
|
||||||
|
)
|
||||||
|
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.timeline_detail(source)
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
finally:
|
||||||
|
model.close()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (LidarGroundError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="K1 local-surface timeline не прошёл проверку целостности",
|
||||||
|
) from exc
|
||||||
|
|
||||||
@router.get("/local-surfaces/{model_id}/frames/{frame_index}")
|
@router.get("/local-surfaces/{model_id}/frames/{frame_index}")
|
||||||
def get_k1_local_surface_frame(
|
def get_k1_local_surface_frame(
|
||||||
model_id: str,
|
model_id: str,
|
||||||
|
|
|
||||||
|
|
@ -164,10 +164,21 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only(
|
||||||
assert model.report["surface_model"]["hardcoded_height_m"] is None
|
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["occupancy_policy"]["absence_of_points_means_free"] is False
|
||||||
assert model.report["metrics"]["frames"]["valid"] >= 5
|
assert model.report["metrics"]["frames"]["valid"] >= 5
|
||||||
|
temporal = model.report["metrics"]["temporal_qualification"]
|
||||||
|
assert temporal["prediction"]["current_frame_excluded"] is True
|
||||||
|
assert temporal["prediction"]["sample_count"] >= 4
|
||||||
|
assert temporal["prediction"]["residual_p95_m"]["p95"] < 0.05
|
||||||
|
assert temporal["stability"]["sample_count"] >= 4
|
||||||
|
assert temporal["step_candidates"]["is_ground_truth"] is False
|
||||||
|
assert temporal["step_candidates"]["frames_with_candidates"] > 0
|
||||||
assert detail["valid"] is True
|
assert detail["valid"] is True
|
||||||
assert 1.2 < detail["surface"]["sensor_height_m"] < 1.6
|
assert 1.2 < detail["surface"]["sensor_height_m"] < 1.6
|
||||||
assert detail["counts"]["surface"] > 50
|
assert detail["counts"]["surface"] > 50
|
||||||
assert detail["counts"]["occupied"] > 0
|
assert detail["counts"]["occupied"] > 0
|
||||||
|
assert detail["counts"]["step_candidate"] > 0
|
||||||
|
assert detail["prediction"]["current_frame_excluded"] is True
|
||||||
|
assert detail["prediction"]["available"] is True
|
||||||
|
assert detail["temporal"]["compared"] is True
|
||||||
assert detail["authority"]["commands_enabled"] is False
|
assert detail["authority"]["commands_enabled"] is False
|
||||||
assert "1.27" not in repr({"identity": model.identity, "report": model.report})
|
assert "1.27" not in repr({"identity": model.identity, "report": model.report})
|
||||||
assert str(tmp_path) not in repr(detail)
|
assert str(tmp_path) not in repr(detail)
|
||||||
|
|
@ -187,9 +198,18 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only(
|
||||||
router,
|
router,
|
||||||
"/api/v1/lidar/local-surfaces/{model_id}/frames/{frame_index}",
|
"/api/v1/lidar/local-surfaces/{model_id}/frames/{frame_index}",
|
||||||
)
|
)
|
||||||
|
timeline_route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/lidar/local-surfaces/{model_id}/timeline",
|
||||||
|
)
|
||||||
catalog = catalog_route(limit=10) # type: ignore[operator]
|
catalog = catalog_route(limit=10) # type: ignore[operator]
|
||||||
frame = frame_route(model_id=output.name, frame_index=2) # type: ignore[operator]
|
frame = frame_route(model_id=output.name, frame_index=2) # type: ignore[operator]
|
||||||
|
timeline = timeline_route(model_id=output.name) # type: ignore[operator]
|
||||||
assert catalog["valid_total"] == 1
|
assert catalog["valid_total"] == 1
|
||||||
assert catalog["items"][0]["status"] == "diagnostic-only"
|
assert catalog["items"][0]["status"] == "diagnostic-only"
|
||||||
assert frame["model_id"] == output.name
|
assert frame["model_id"] == output.name
|
||||||
assert frame["access"] == "read-only"
|
assert frame["access"] == "read-only"
|
||||||
|
assert timeline["frame_count"] == 8
|
||||||
|
assert sum(timeline["prediction_available"]) >= 4
|
||||||
|
assert len(timeline["temporal_jump"]) == 8
|
||||||
|
assert str(tmp_path) not in repr(timeline)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue