feat: qualify K1 local surface over time
This commit is contained in:
@@ -48,6 +48,28 @@ export interface LidarLocalSurfaceModel {
|
||||
confidence: LidarLocalSurfaceDistribution;
|
||||
poseBindingAgeMs: LidarLocalSurfaceDistribution;
|
||||
surfaceMaxAgeMs: LidarLocalSurfaceDistribution;
|
||||
temporalQualification: {
|
||||
prediction: {
|
||||
currentFrameExcluded: true;
|
||||
sampleCount: number;
|
||||
residualP50M: LidarLocalSurfaceDistribution;
|
||||
residualP95M: LidarLocalSurfaceDistribution;
|
||||
inlierFraction: LidarLocalSurfaceDistribution;
|
||||
};
|
||||
stability: {
|
||||
sampleCount: number;
|
||||
heightDeltaM: LidarLocalSurfaceDistribution;
|
||||
slopeDeltaDeg: LidarLocalSurfaceDistribution;
|
||||
roughnessDeltaM: LidarLocalSurfaceDistribution;
|
||||
jumpCount: number;
|
||||
};
|
||||
stepCandidates: {
|
||||
isGroundTruth: false;
|
||||
framesWithCandidates: number;
|
||||
cellCount: LidarLocalSurfaceDistribution;
|
||||
pointCount: LidarLocalSurfaceDistribution;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
anchors: LidarLocalSurfaceAnchor[];
|
||||
occupancyPolicy: {
|
||||
@@ -88,6 +110,7 @@ export interface LidarLocalSurfaceFrame {
|
||||
pointsXyzM: Array<[number, number, number]>;
|
||||
pointClass: number[];
|
||||
pointHeightM: number[];
|
||||
pointStepCandidate: number[];
|
||||
pose: {
|
||||
positionXyzM: [number, number, number];
|
||||
orientationXyzw: [number, number, number, number];
|
||||
@@ -108,12 +131,56 @@ export interface LidarLocalSurfaceFrame {
|
||||
surface: number;
|
||||
occupied: number;
|
||||
belowSurface: number;
|
||||
stepCandidate: number;
|
||||
};
|
||||
prediction: {
|
||||
available: boolean;
|
||||
currentFrameExcluded: true;
|
||||
cellCount: number;
|
||||
residualP50M: number;
|
||||
residualP95M: number;
|
||||
inlierFraction: number;
|
||||
};
|
||||
temporal: {
|
||||
compared: boolean;
|
||||
heightDeltaM: number;
|
||||
slopeDeltaDeg: number;
|
||||
roughnessDeltaM: number;
|
||||
jump: boolean;
|
||||
stepCandidateCellCount: number;
|
||||
};
|
||||
occupancyPolicy: LidarLocalSurfaceModel["occupancyPolicy"];
|
||||
groundTruth: false;
|
||||
authority: LidarLocalSurfaceModel["authority"];
|
||||
}
|
||||
|
||||
export interface LidarLocalSurfaceTimeline {
|
||||
modelId: string;
|
||||
sourcePackId: string;
|
||||
sessionId: string;
|
||||
frameCount: number;
|
||||
sourceFrameIndex: number[];
|
||||
sessionSeconds: number[];
|
||||
sourceAvailable: number[];
|
||||
valid: number[];
|
||||
predictionAvailable: number[];
|
||||
predictionResidualP50M: number[];
|
||||
predictionResidualP95M: number[];
|
||||
predictionInlierFraction: number[];
|
||||
sensorHeightM: number[];
|
||||
slopeDeg: number[];
|
||||
roughnessM: number[];
|
||||
confidence: number[];
|
||||
temporalCompared: number[];
|
||||
heightDeltaM: number[];
|
||||
slopeDeltaDeg: number[];
|
||||
roughnessDeltaM: number[];
|
||||
temporalJump: number[];
|
||||
stepCandidatePointCount: number[];
|
||||
groundTruth: false;
|
||||
authority: LidarLocalSurfaceModel["authority"];
|
||||
}
|
||||
|
||||
export class LidarLocalSurfaceContractError extends Error {}
|
||||
|
||||
export class LidarLocalSurfaceApiError extends Error {
|
||||
@@ -199,6 +266,39 @@ function tuple(
|
||||
return values;
|
||||
}
|
||||
|
||||
function finiteVector(
|
||||
value: unknown,
|
||||
length: number,
|
||||
label: string,
|
||||
): number[] {
|
||||
const values = array(value, label).map((item, index) =>
|
||||
finite(item, `${label}[${index}]`)
|
||||
);
|
||||
if (values.length !== length) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: неверная длина`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function integerVector(
|
||||
value: unknown,
|
||||
length: number,
|
||||
label: string,
|
||||
maximum?: number,
|
||||
): number[] {
|
||||
const values = array(value, label).map((item, index) => {
|
||||
const result = integer(item, `${label}[${index}]`);
|
||||
if (maximum !== undefined && result > maximum) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: значение вне диапазона`);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
if (values.length !== length) {
|
||||
throw new LidarLocalSurfaceContractError(`${label}: неверная длина`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
@@ -288,6 +388,90 @@ function anchor(value: unknown): LidarLocalSurfaceAnchor {
|
||||
};
|
||||
}
|
||||
|
||||
function temporalQualification(
|
||||
value: unknown,
|
||||
): LidarLocalSurfaceModel["metrics"]["temporalQualification"] {
|
||||
if (value === undefined || value === null) return null;
|
||||
const source = record(value, "temporal_qualification");
|
||||
const prediction = record(source.prediction, "temporal_qualification.prediction");
|
||||
const stability = record(source.stability, "temporal_qualification.stability");
|
||||
const steps = record(
|
||||
source.step_candidates,
|
||||
"temporal_qualification.step_candidates",
|
||||
);
|
||||
if (
|
||||
prediction.current_frame_excluded !== true
|
||||
|| steps.is_ground_truth !== false
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR temporal qualification завышает evidence",
|
||||
);
|
||||
}
|
||||
const predictionSampleCount = integer(
|
||||
prediction.sample_count,
|
||||
"prediction.sample_count",
|
||||
);
|
||||
const stabilitySampleCount = integer(
|
||||
stability.sample_count,
|
||||
"stability.sample_count",
|
||||
);
|
||||
const jumpCount = integer(stability.jump_count, "stability.jump_count");
|
||||
if (jumpCount > stabilitySampleCount) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR temporal jump count несовместим",
|
||||
);
|
||||
}
|
||||
return {
|
||||
prediction: {
|
||||
currentFrameExcluded: true,
|
||||
sampleCount: predictionSampleCount,
|
||||
residualP50M: distribution(
|
||||
prediction.residual_p50_m,
|
||||
"prediction.residual_p50_m",
|
||||
),
|
||||
residualP95M: distribution(
|
||||
prediction.residual_p95_m,
|
||||
"prediction.residual_p95_m",
|
||||
),
|
||||
inlierFraction: distribution(
|
||||
prediction.inlier_fraction,
|
||||
"prediction.inlier_fraction",
|
||||
),
|
||||
},
|
||||
stability: {
|
||||
sampleCount: stabilitySampleCount,
|
||||
heightDeltaM: distribution(
|
||||
stability.height_delta_m,
|
||||
"stability.height_delta_m",
|
||||
),
|
||||
slopeDeltaDeg: distribution(
|
||||
stability.slope_delta_deg,
|
||||
"stability.slope_delta_deg",
|
||||
),
|
||||
roughnessDeltaM: distribution(
|
||||
stability.roughness_delta_m,
|
||||
"stability.roughness_delta_m",
|
||||
),
|
||||
jumpCount,
|
||||
},
|
||||
stepCandidates: {
|
||||
isGroundTruth: false,
|
||||
framesWithCandidates: integer(
|
||||
steps.frames_with_candidates,
|
||||
"step_candidates.frames_with_candidates",
|
||||
),
|
||||
cellCount: distribution(
|
||||
steps.cell_count,
|
||||
"step_candidates.cell_count",
|
||||
),
|
||||
pointCount: distribution(
|
||||
steps.point_count,
|
||||
"step_candidates.point_count",
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function model(value: unknown): LidarLocalSurfaceModel {
|
||||
const source = record(value, "LiDAR local-surface model");
|
||||
const sourceEvidence = record(source.source, "source");
|
||||
@@ -373,6 +557,9 @@ function model(value: unknown): LidarLocalSurfaceModel {
|
||||
metrics.surface_max_age_ms,
|
||||
"surface_max_age_ms",
|
||||
),
|
||||
temporalQualification: temporalQualification(
|
||||
metrics.temporal_qualification,
|
||||
),
|
||||
},
|
||||
anchors,
|
||||
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
|
||||
@@ -446,10 +633,23 @@ export function parseLidarLocalSurfaceFrame(
|
||||
const pointHeightM = array(source.point_height_m, "point_height_m").map(
|
||||
(item, index) => finite(item, `point_height_m[${index}]`),
|
||||
);
|
||||
const pointStepCandidate = array(
|
||||
source.point_step_candidate,
|
||||
"point_step_candidate",
|
||||
).map((item, index) => {
|
||||
const value = integer(item, `point_step_candidate[${index}]`);
|
||||
if (value > 1) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Некорректная step-candidate mask",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (
|
||||
pointsXyzM.length !== pointCount
|
||||
|| pointClass.length !== pointCount
|
||||
|| pointHeightM.length !== pointCount
|
||||
|| pointStepCandidate.length !== pointCount
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface point arrays расходятся",
|
||||
@@ -457,12 +657,15 @@ export function parseLidarLocalSurfaceFrame(
|
||||
}
|
||||
const pose = record(source.pose, "pose");
|
||||
const surface = record(source.surface, "surface");
|
||||
const prediction = record(source.prediction, "prediction");
|
||||
const temporal = record(source.temporal, "temporal");
|
||||
const counts = record(source.counts, "counts");
|
||||
const parsedCounts = {
|
||||
classified: integer(counts.classified, "counts.classified"),
|
||||
surface: integer(counts.surface, "counts.surface"),
|
||||
occupied: integer(counts.occupied, "counts.occupied"),
|
||||
belowSurface: integer(counts.below_surface, "counts.below_surface"),
|
||||
stepCandidate: integer(counts.step_candidate, "counts.step_candidate"),
|
||||
};
|
||||
if (
|
||||
parsedCounts.classified !== pointClass.filter((item) => item !== 0).length
|
||||
@@ -470,6 +673,8 @@ export function parseLidarLocalSurfaceFrame(
|
||||
|| parsedCounts.occupied !== pointClass.filter((item) => item === 2).length
|
||||
|| parsedCounts.belowSurface
|
||||
!== pointClass.filter((item) => item === 3).length
|
||||
|| parsedCounts.stepCandidate
|
||||
!== pointStepCandidate.filter((item) => item === 1).length
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface counts расходятся",
|
||||
@@ -486,6 +691,20 @@ export function parseLidarLocalSurfaceFrame(
|
||||
4,
|
||||
"surface.plane_coefficients_map",
|
||||
);
|
||||
if (prediction.current_frame_excluded !== true) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Текущий кадр попал в prediction input",
|
||||
);
|
||||
}
|
||||
const predictionInlierFraction = finite(
|
||||
prediction.inlier_fraction,
|
||||
"prediction.inlier_fraction",
|
||||
);
|
||||
if (predictionInlierFraction < 0 || predictionInlierFraction > 1) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Prediction inlier fraction несовместим",
|
||||
);
|
||||
}
|
||||
return {
|
||||
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||
@@ -503,6 +722,7 @@ export function parseLidarLocalSurfaceFrame(
|
||||
pointsXyzM,
|
||||
pointClass,
|
||||
pointHeightM,
|
||||
pointStepCandidate,
|
||||
pose: {
|
||||
positionXyzM: [position[0], position[1], position[2]],
|
||||
orientationXyzw: [
|
||||
@@ -530,12 +750,181 @@ export function parseLidarLocalSurfaceFrame(
|
||||
),
|
||||
},
|
||||
counts: parsedCounts,
|
||||
prediction: {
|
||||
available: boolean(prediction.available, "prediction.available"),
|
||||
currentFrameExcluded: true,
|
||||
cellCount: integer(prediction.cell_count, "prediction.cell_count"),
|
||||
residualP50M: finite(
|
||||
prediction.residual_p50_m,
|
||||
"prediction.residual_p50_m",
|
||||
),
|
||||
residualP95M: finite(
|
||||
prediction.residual_p95_m,
|
||||
"prediction.residual_p95_m",
|
||||
),
|
||||
inlierFraction: predictionInlierFraction,
|
||||
},
|
||||
temporal: {
|
||||
compared: boolean(temporal.compared, "temporal.compared"),
|
||||
heightDeltaM: finite(
|
||||
temporal.height_delta_m,
|
||||
"temporal.height_delta_m",
|
||||
),
|
||||
slopeDeltaDeg: finite(
|
||||
temporal.slope_delta_deg,
|
||||
"temporal.slope_delta_deg",
|
||||
),
|
||||
roughnessDeltaM: finite(
|
||||
temporal.roughness_delta_m,
|
||||
"temporal.roughness_delta_m",
|
||||
),
|
||||
jump: boolean(temporal.jump, "temporal.jump"),
|
||||
stepCandidateCellCount: integer(
|
||||
temporal.step_candidate_cell_count,
|
||||
"temporal.step_candidate_cell_count",
|
||||
),
|
||||
},
|
||||
occupancyPolicy: occupancyPolicy(source.occupancy_policy),
|
||||
groundTruth: false,
|
||||
authority: authority(source.authority),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLidarLocalSurfaceTimeline(
|
||||
value: unknown,
|
||||
): LidarLocalSurfaceTimeline {
|
||||
const source = record(value, "LiDAR local-surface timeline");
|
||||
if (
|
||||
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-timeline/v1`
|
||||
|| source.access !== "read-only"
|
||||
|| source.ground_truth !== false
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface timeline несовместим",
|
||||
);
|
||||
}
|
||||
const frameCount = integer(source.frame_count, "frame_count");
|
||||
if (frameCount < 1 || frameCount > 100_000) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface timeline слишком большой",
|
||||
);
|
||||
}
|
||||
const sourceFrameIndex = integerVector(
|
||||
source.source_frame_index,
|
||||
frameCount,
|
||||
"source_frame_index",
|
||||
);
|
||||
const sessionSeconds = finiteVector(
|
||||
source.session_seconds,
|
||||
frameCount,
|
||||
"session_seconds",
|
||||
);
|
||||
const sourceAvailable = integerVector(
|
||||
source.source_available,
|
||||
frameCount,
|
||||
"source_available",
|
||||
1,
|
||||
);
|
||||
const valid = integerVector(source.valid, frameCount, "valid", 1);
|
||||
const predictionAvailable = integerVector(
|
||||
source.prediction_available,
|
||||
frameCount,
|
||||
"prediction_available",
|
||||
1,
|
||||
);
|
||||
const predictionResidualP50M = finiteVector(
|
||||
source.prediction_residual_p50_m,
|
||||
frameCount,
|
||||
"prediction_residual_p50_m",
|
||||
);
|
||||
const predictionResidualP95M = finiteVector(
|
||||
source.prediction_residual_p95_m,
|
||||
frameCount,
|
||||
"prediction_residual_p95_m",
|
||||
);
|
||||
const predictionInlierFraction = finiteVector(
|
||||
source.prediction_inlier_fraction,
|
||||
frameCount,
|
||||
"prediction_inlier_fraction",
|
||||
);
|
||||
const confidence = finiteVector(source.confidence, frameCount, "confidence");
|
||||
const temporalCompared = integerVector(
|
||||
source.temporal_compared,
|
||||
frameCount,
|
||||
"temporal_compared",
|
||||
1,
|
||||
);
|
||||
const temporalJump = integerVector(
|
||||
source.temporal_jump,
|
||||
frameCount,
|
||||
"temporal_jump",
|
||||
1,
|
||||
);
|
||||
if (
|
||||
sourceFrameIndex.some(
|
||||
(item, index) => index > 0 && item <= sourceFrameIndex[index - 1],
|
||||
)
|
||||
|| sessionSeconds.some(
|
||||
(item, index) => index > 0 && item <= sessionSeconds[index - 1],
|
||||
)
|
||||
|| predictionInlierFraction.some((item) => item < 0 || item > 1)
|
||||
|| confidence.some((item) => item < 0 || item > 1)
|
||||
|| temporalJump.some(
|
||||
(item, index) => item === 1 && temporalCompared[index] !== 1,
|
||||
)
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface timeline content несовместим",
|
||||
);
|
||||
}
|
||||
return {
|
||||
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||
sessionId: text(source.session_id, "session_id", SAFE_ID),
|
||||
frameCount,
|
||||
sourceFrameIndex,
|
||||
sessionSeconds,
|
||||
sourceAvailable,
|
||||
valid,
|
||||
predictionAvailable,
|
||||
predictionResidualP50M,
|
||||
predictionResidualP95M,
|
||||
predictionInlierFraction,
|
||||
sensorHeightM: finiteVector(
|
||||
source.sensor_height_m,
|
||||
frameCount,
|
||||
"sensor_height_m",
|
||||
),
|
||||
slopeDeg: finiteVector(source.slope_deg, frameCount, "slope_deg"),
|
||||
roughnessM: finiteVector(source.roughness_m, frameCount, "roughness_m"),
|
||||
confidence,
|
||||
temporalCompared,
|
||||
heightDeltaM: finiteVector(
|
||||
source.height_delta_m,
|
||||
frameCount,
|
||||
"height_delta_m",
|
||||
),
|
||||
slopeDeltaDeg: finiteVector(
|
||||
source.slope_delta_deg,
|
||||
frameCount,
|
||||
"slope_delta_deg",
|
||||
),
|
||||
roughnessDeltaM: finiteVector(
|
||||
source.roughness_delta_m,
|
||||
frameCount,
|
||||
"roughness_delta_m",
|
||||
),
|
||||
temporalJump,
|
||||
stepCandidatePointCount: integerVector(
|
||||
source.step_candidate_point_count,
|
||||
frameCount,
|
||||
"step_candidate_point_count",
|
||||
),
|
||||
groundTruth: false,
|
||||
authority: authority(source.authority),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
@@ -600,3 +989,29 @@ export async function fetchLidarLocalSurfaceFrame(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLidarLocalSurfaceTimeline(
|
||||
modelId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||
): Promise<LidarLocalSurfaceTimeline> {
|
||||
if (!SAFE_MODEL_ID.test(modelId)) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Некорректный LiDAR local-surface timeline",
|
||||
);
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/lidar/local-surfaces/${modelId}/timeline`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseLidarLocalSurfaceTimeline(
|
||||
await responseJson(
|
||||
response,
|
||||
"Не удалось получить LiDAR local-surface timeline.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2884,6 +2884,97 @@
|
||||
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 {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
@@ -2953,6 +3044,10 @@
|
||||
background: #a1b87d;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend i[data-class="step"] {
|
||||
background: #f5c23d;
|
||||
}
|
||||
|
||||
.lidar-local-surface__legend i[data-class="occupied"] {
|
||||
background: #f0783d;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface LidarGroundPointCloudFrame {
|
||||
groundTruthGround?: number[];
|
||||
evaluationMask?: number[];
|
||||
localSurfaceClass?: number[];
|
||||
localStepCandidate?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +72,10 @@ function frameColors(
|
||||
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||
if (mode === "local-surface") {
|
||||
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);
|
||||
} else if (localClass === 2) {
|
||||
setRgb(colors, offset, 0.94, 0.48, 0.24);
|
||||
|
||||
@@ -3,11 +3,14 @@ import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchLidarLocalSurfaceFrame,
|
||||
fetchLidarLocalSurfaceTimeline,
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceFrame,
|
||||
type LidarLocalSurfaceModel,
|
||||
type LidarLocalSurfaceTimeline as Timeline,
|
||||
} from "../core/lidar/localSurface";
|
||||
import { LidarGroundPointCloud } from "./LidarGroundPointCloud";
|
||||
import { LidarLocalSurfaceTimeline } from "./LidarLocalSurfaceTimeline";
|
||||
|
||||
function formatNumber(value: number | null, digits = 2): string {
|
||||
if (value === null) return "—";
|
||||
@@ -29,6 +32,10 @@ export function LidarLocalSurfacePanel({
|
||||
}) {
|
||||
const [model, setModel] = useState<LidarLocalSurfaceModel | 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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -48,12 +55,19 @@ export function LidarLocalSurfacePanel({
|
||||
void fetchLidarLocalSurfaces({ signal: controller.signal })
|
||||
.then((catalog) => {
|
||||
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) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setModel(null);
|
||||
setFrame(null);
|
||||
setTimeline(null);
|
||||
setError(errorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -63,14 +77,38 @@ export function LidarLocalSurfacePanel({
|
||||
}, [reloadGeneration]);
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchLidarLocalSurfaceFrame(model.modelId, anchor.frameIndex, {
|
||||
void fetchLidarLocalSurfaceFrame(model.modelId, selectedFrameIndex, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((nextFrame) => {
|
||||
@@ -85,7 +123,7 @@ export function LidarLocalSurfacePanel({
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [anchor, model]);
|
||||
}, [model, selectedFrameIndex]);
|
||||
|
||||
const cloudFrame = useMemo(() => {
|
||||
if (!frame) return null;
|
||||
@@ -101,6 +139,7 @@ export function LidarLocalSurfacePanel({
|
||||
candidateAssigned: emptyMask,
|
||||
disagreement: emptyMask,
|
||||
localSurfaceClass: frame.pointClass,
|
||||
localStepCandidate: frame.pointStepCandidate,
|
||||
},
|
||||
};
|
||||
}, [frame]);
|
||||
@@ -119,15 +158,27 @@ export function LidarLocalSurfacePanel({
|
||||
<span className="section-eyebrow">ЛОКАЛЬНАЯ МОДЕЛЬ LIDAR · L2.6</span>
|
||||
<h3>Поверхность и наблюдаемые препятствия</h3>
|
||||
<p>
|
||||
Производная от неизменяемого RAVNOVES00: высота и уклон
|
||||
вычисляются из текущей позы и локальной поверхности, без константы
|
||||
1,27 м и без команд в сканер.
|
||||
Поверхность строится по предыдущему TTL-окну и проверяется на
|
||||
следующем кадре. Текущий кадр исключён из prediction input;
|
||||
константа 1,27 м и команды в сканер не используются.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={error ? "danger" : frame?.valid ? "success" : "warning"}>
|
||||
<StatusBadge
|
||||
tone={
|
||||
error
|
||||
? "danger"
|
||||
: frame?.temporal.jump
|
||||
? "warning"
|
||||
: frame?.valid
|
||||
? "success"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{error
|
||||
? "Недоступно"
|
||||
: frame?.valid
|
||||
: frame?.temporal.jump
|
||||
? "Temporal jump"
|
||||
: frame?.valid
|
||||
? "Кадр рассчитан"
|
||||
: "Диагностический режим"}
|
||||
</StatusBadge>
|
||||
@@ -143,20 +194,46 @@ export function LidarLocalSurfacePanel({
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Высота над поверхностью · p50</span>
|
||||
<strong>{formatNumber(model.metrics.sensorHeightM.p50)} м</strong>
|
||||
<span>Prediction residual · p50</span>
|
||||
<strong>
|
||||
{formatNumber(
|
||||
model.metrics.temporalQualification?.prediction
|
||||
.residualP50M.p50 ?? null,
|
||||
3,
|
||||
)}{" "}
|
||||
м
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Шероховатость · p95</span>
|
||||
<strong>{formatNumber(model.metrics.roughnessM.p95, 3)} м</strong>
|
||||
<span>Prediction inliers · p50</span>
|
||||
<strong>
|
||||
{formatNumber(
|
||||
(model.metrics.temporalQualification?.prediction
|
||||
.inlierFraction.p50 ?? 0) * 100,
|
||||
1,
|
||||
)}
|
||||
%
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Pose binding · p95</span>
|
||||
<strong>{formatNumber(model.metrics.poseBindingAgeMs.p95)} мс</strong>
|
||||
<span>Temporal jumps</span>
|
||||
<strong>
|
||||
{model.metrics.temporalQualification?.stability.jumpCount ?? 0}
|
||||
{" / "}
|
||||
{model.metrics.temporalQualification?.stability.sampleCount ?? 0}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{timeline && selectedFrameIndex !== null ? (
|
||||
<LidarLocalSurfaceTimeline
|
||||
timeline={timeline}
|
||||
selectedFrameIndex={selectedFrameIndex}
|
||||
onSelectFrame={setSelectedFrameIndex}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="lidar-local-surface__stage">
|
||||
{cloudFrame && frame ? (
|
||||
<LidarGroundPointCloud frame={cloudFrame} mode="local-surface" />
|
||||
@@ -192,6 +269,22 @@ export function LidarLocalSurfacePanel({
|
||||
<dt>Confidence</dt>
|
||||
<dd>{formatNumber(frame.surface.confidence * 100, 0)}%</dd>
|
||||
</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>
|
||||
<dt>Поверхность</dt>
|
||||
<dd>{frame.counts.surface.toLocaleString("ru-RU")} точек</dd>
|
||||
@@ -200,16 +293,24 @@ export function LidarLocalSurfacePanel({
|
||||
<dt>Препятствия</dt>
|
||||
<dd>{frame.counts.occupied.toLocaleString("ru-RU")} точек</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Перепады-кандидаты</dt>
|
||||
<dd>
|
||||
{frame.counts.stepCandidate.toLocaleString("ru-RU")} точек
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="lidar-local-surface__legend">
|
||||
<span><i data-class="step" />Перепад / бордюр-кандидат</span>
|
||||
<span><i data-class="surface" />Наблюдаемая поверхность</span>
|
||||
<span><i data-class="occupied" />Выше поверхности</span>
|
||||
<span><i data-class="below" />Нижний выброс</span>
|
||||
<span><i data-class="unknown" />Не классифицировано</span>
|
||||
</div>
|
||||
<p>
|
||||
Пустота между точками остаётся unknown. Этот слой не разрешает
|
||||
движение и не меняет постоянную реконструкцию территории.
|
||||
Жёлтый слой — геометрический кандидат, не распознанный бордюр и
|
||||
не ground truth. Пустота остаётся unknown; движение этим слоем
|
||||
не разрешается.
|
||||
</p>
|
||||
</aside>
|
||||
) : 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 parseLidarLocalSurfaceCatalog;
|
||||
let parseLidarLocalSurfaceFrame;
|
||||
let parseLidarLocalSurfaceTimeline;
|
||||
let fetchLidarLocalSurfaceTimeline;
|
||||
let LidarLocalSurfaceContractError;
|
||||
|
||||
const modelId = `k1-local-surface-${"a".repeat(64)}`;
|
||||
@@ -71,6 +73,28 @@ function model(overrides = {}) {
|
||||
confidence: distribution(0.8),
|
||||
pose_binding_age_ms: distribution(7),
|
||||
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,
|
||||
},
|
||||
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]],
|
||||
point_class: [1, 1, 2, 3],
|
||||
point_height_m: [0, 0.02, 0.7, -0.3],
|
||||
point_step_candidate: [0, 1, 0, 0],
|
||||
pose: {
|
||||
position_xyz_m: [0, 0, 1.3],
|
||||
orientation_xyzw: [0, 0, 0, 1],
|
||||
@@ -147,6 +172,23 @@ function frame(overrides = {}) {
|
||||
surface: 2,
|
||||
occupied: 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: {},
|
||||
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 () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
@@ -169,6 +246,8 @@ before(async () => {
|
||||
({
|
||||
parseLidarLocalSurfaceCatalog,
|
||||
parseLidarLocalSurfaceFrame,
|
||||
parseLidarLocalSurfaceTimeline,
|
||||
fetchLidarLocalSurfaceTimeline,
|
||||
LidarLocalSurfaceContractError,
|
||||
} = 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].metrics.sensorHeightM.p50, 1.3);
|
||||
assert.equal(decoded.items[0].anchors[0].sourceFrameIndex, 1350);
|
||||
assert.equal(
|
||||
decoded.items[0].metrics.temporalQualification.prediction.sampleCount,
|
||||
9,
|
||||
);
|
||||
|
||||
const decodedFrame = parseLidarLocalSurfaceFrame(frame());
|
||||
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);
|
||||
|
||||
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", () => {
|
||||
@@ -210,3 +300,21 @@ test("rejects command authority", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user