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