feat: add passive K1 local surface replay
This commit is contained in:
@@ -9,7 +9,8 @@ export type LidarGroundViewMode =
|
||||
| "disagreement"
|
||||
| "candidate-disagreement"
|
||||
| "semantic"
|
||||
| "ground-truth";
|
||||
| "ground-truth"
|
||||
| "local-surface";
|
||||
|
||||
export interface LidarGroundPointCloudFrame {
|
||||
pointCount: number;
|
||||
@@ -25,6 +26,7 @@ export interface LidarGroundPointCloudFrame {
|
||||
candidateDisagreement?: number[];
|
||||
groundTruthGround?: number[];
|
||||
evaluationMask?: number[];
|
||||
localSurfaceClass?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,7 +69,18 @@ function frameColors(
|
||||
const current = frame.masks.currentGround[index] === 1;
|
||||
const candidate = frame.masks.candidateGround[index] === 1;
|
||||
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
|
||||
if (mode === "intensity") {
|
||||
if (mode === "local-surface") {
|
||||
const localClass = frame.masks.localSurfaceClass?.[index] ?? 0;
|
||||
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);
|
||||
} else if (localClass === 3) {
|
||||
setRgb(colors, offset, 0.66, 0.32, 0.38);
|
||||
} else {
|
||||
setRgb(colors, offset, 0.27, 0.29, 0.28);
|
||||
}
|
||||
} else if (mode === "intensity") {
|
||||
const intensity = (frame.intensity0To255?.[index] ?? 96) / 255;
|
||||
const neutral = 0.16 + intensity * 0.8;
|
||||
setRgb(
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchLidarLocalSurfaceFrame,
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceFrame,
|
||||
type LidarLocalSurfaceModel,
|
||||
} from "../core/lidar/localSurface";
|
||||
import { LidarGroundPointCloud } from "./LidarGroundPointCloud";
|
||||
|
||||
function formatNumber(value: number | null, digits = 2): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Не удалось открыть локальную модель LiDAR.";
|
||||
}
|
||||
|
||||
export function LidarLocalSurfacePanel({
|
||||
selectedWindowKey,
|
||||
reloadGeneration,
|
||||
}: {
|
||||
selectedWindowKey: string | null;
|
||||
reloadGeneration: number;
|
||||
}) {
|
||||
const [model, setModel] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [frame, setFrame] = useState<LidarLocalSurfaceFrame | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const anchor = useMemo(() => {
|
||||
if (!model) return null;
|
||||
return (
|
||||
model.anchors.find((item) => item.key === selectedWindowKey)
|
||||
?? model.anchors[0]
|
||||
?? null
|
||||
);
|
||||
}, [model, selectedWindowKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchLidarLocalSurfaces({ signal: controller.signal })
|
||||
.then((catalog) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setModel(catalog.items[0] ?? null);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setModel(null);
|
||||
setFrame(null);
|
||||
setError(errorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [reloadGeneration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model || !anchor) {
|
||||
setFrame(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchLidarLocalSurfaceFrame(model.modelId, anchor.frameIndex, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((nextFrame) => {
|
||||
if (!controller.signal.aborted) setFrame(nextFrame);
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFrame(null);
|
||||
setError(errorMessage(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [anchor, model]);
|
||||
|
||||
const cloudFrame = useMemo(() => {
|
||||
if (!frame) return null;
|
||||
const emptyMask = new Array<number>(frame.pointCount).fill(0);
|
||||
return {
|
||||
pointCount: frame.pointCount,
|
||||
pointsXyzM: frame.pointsXyzM,
|
||||
intensity0To255: null,
|
||||
masks: {
|
||||
currentGround: emptyMask,
|
||||
currentAssigned: emptyMask,
|
||||
candidateGround: emptyMask,
|
||||
candidateAssigned: emptyMask,
|
||||
disagreement: emptyMask,
|
||||
localSurfaceClass: frame.pointClass,
|
||||
},
|
||||
};
|
||||
}, [frame]);
|
||||
|
||||
if (!model && !loading && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="lidar-local-surface"
|
||||
aria-label="Динамическая локальная поверхность LiDAR"
|
||||
>
|
||||
<header className="lidar-local-surface__heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНАЯ МОДЕЛЬ LIDAR · L2.6</span>
|
||||
<h3>Поверхность и наблюдаемые препятствия</h3>
|
||||
<p>
|
||||
Производная от неизменяемого RAVNOVES00: высота и уклон
|
||||
вычисляются из текущей позы и локальной поверхности, без константы
|
||||
1,27 м и без команд в сканер.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={error ? "danger" : frame?.valid ? "success" : "warning"}>
|
||||
{error
|
||||
? "Недоступно"
|
||||
: frame?.valid
|
||||
? "Кадр рассчитан"
|
||||
: "Диагностический режим"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{model ? (
|
||||
<div className="lidar-local-surface__summary">
|
||||
<div>
|
||||
<span>Покрытие записи</span>
|
||||
<strong>
|
||||
{model.metrics.frames.valid.toLocaleString("ru-RU")} /{" "}
|
||||
{model.metrics.frames.sourceAvailable.toLocaleString("ru-RU")}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Высота над поверхностью · p50</span>
|
||||
<strong>{formatNumber(model.metrics.sensorHeightM.p50)} м</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Шероховатость · p95</span>
|
||||
<strong>{formatNumber(model.metrics.roughnessM.p95, 3)} м</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Pose binding · p95</span>
|
||||
<strong>{formatNumber(model.metrics.poseBindingAgeMs.p95)} мс</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lidar-local-surface__stage">
|
||||
{cloudFrame && frame ? (
|
||||
<LidarGroundPointCloud frame={cloudFrame} mode="local-surface" />
|
||||
) : (
|
||||
<div className="lidar-ground-scene-placeholder">
|
||||
<StatusBadge tone={error ? "danger" : "accent"}>
|
||||
{error ? "Ошибка" : "Загрузка"}
|
||||
</StatusBadge>
|
||||
<span>{error ?? "Читаем производную выбранной сцены…"}</span>
|
||||
</div>
|
||||
)}
|
||||
{frame ? (
|
||||
<aside className="lidar-local-surface__frame">
|
||||
<div>
|
||||
<span>Исходный кадр</span>
|
||||
<strong>{frame.sourceFrameIndex}</strong>
|
||||
<small>t = {formatNumber(frame.sessionSeconds)} с</small>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Высота</dt>
|
||||
<dd>{formatNumber(frame.surface.sensorHeightM)} м</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Уклон</dt>
|
||||
<dd>{formatNumber(frame.surface.slopeDeg)}°</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Шероховатость</dt>
|
||||
<dd>{formatNumber(frame.surface.roughnessM, 3)} м</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Confidence</dt>
|
||||
<dd>{formatNumber(frame.surface.confidence * 100, 0)}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Поверхность</dt>
|
||||
<dd>{frame.counts.surface.toLocaleString("ru-RU")} точек</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Препятствия</dt>
|
||||
<dd>{frame.counts.occupied.toLocaleString("ru-RU")} точек</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="lidar-local-surface__legend">
|
||||
<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. Этот слой не разрешает
|
||||
движение и не меняет постоянную реконструкцию территории.
|
||||
</p>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
import { LidarLocalSurfacePanel } from "./LidarLocalSurfacePanel";
|
||||
|
||||
function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
@@ -460,6 +461,11 @@ export function LidarQualityWorkspace({
|
||||
<span><i data-color="non-ground" />Оба non-ground</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LidarLocalSurfacePanel
|
||||
selectedWindowKey={selectedFieldWindow?.key ?? null}
|
||||
reloadGeneration={reloadGeneration}
|
||||
/>
|
||||
</>
|
||||
) : groundBenchmark ? (
|
||||
<section
|
||||
|
||||
Reference in New Issue
Block a user