feat: qualify K1 local surface over time
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user