feat: qualify K1 local surface over time

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