feat(lidar): add point-aligned ground review

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 09:44:38 +03:00
parent 75a3e669d9
commit 2dfb34ef21
15 changed files with 1548 additions and 140 deletions
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import type { LidarGroundFrame } from "../core/lidar/replayQuality";
export type LidarGroundViewMode =
| "intensity"
| "current"
| "candidate"
| "disagreement";
interface LidarGroundPointCloudProps {
frame: LidarGroundFrame;
mode: LidarGroundViewMode;
}
function setRgb(
target: Float32Array,
offset: number,
red: number,
green: number,
blue: number,
) {
target[offset] = red;
target[offset + 1] = green;
target[offset + 2] = blue;
}
function frameColors(
frame: LidarGroundFrame,
mode: LidarGroundViewMode,
): Float32Array {
const colors = new Float32Array(frame.pointCount * 3);
for (let index = 0; index < frame.pointCount; index += 1) {
const offset = index * 3;
const current = frame.masks.currentGround[index] === 1;
const candidate = frame.masks.candidateGround[index] === 1;
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
if (mode === "intensity") {
const intensity = frame.intensity0To255[index] / 255;
setRgb(
colors,
offset,
0.12 + intensity * 0.74,
0.24 + intensity * 0.68,
0.34 + intensity * 0.6,
);
} else if (mode === "current") {
setRgb(
colors,
offset,
current ? 0.73 : 0.29,
current ? 1 : 0.36,
current ? 0.29 : 0.43,
);
} else if (mode === "candidate") {
if (!candidateAssigned) {
setRgb(colors, offset, 1, 0.24, 0.32);
} else {
setRgb(
colors,
offset,
candidate ? 0.24 : 0.29,
candidate ? 0.84 : 0.36,
candidate ? 1 : 0.43,
);
}
} else if (current && candidate) {
setRgb(colors, offset, 0.73, 1, 0.29);
} else if (current) {
setRgb(colors, offset, 1, 0.64, 0.18);
} else if (candidate) {
setRgb(colors, offset, 0.24, 0.84, 1);
} else {
setRgb(colors, offset, 0.24, 0.29, 0.35);
}
}
return colors;
}
export function LidarGroundPointCloud({
frame,
mode,
}: LidarGroundPointCloudProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
const materialRef = useRef<THREE.PointsMaterial | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: "high-performance",
});
} catch {
setRenderError("Браузер не смог создать WebGL-сцену LiDAR.");
return;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x071018, 0.96);
renderer.domElement.setAttribute(
"aria-label",
"Интерактивное облако ground segmentation",
);
host.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x071018, 0.035);
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 1_000);
camera.position.set(6, 4.5, 6);
cameraRef.current = camera;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = true;
controls.enableZoom = true;
controls.minDistance = 0.15;
controls.maxDistance = 200;
controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI;
controls.target.set(0, 0.5, 0);
controls.update();
controlsRef.current = controls;
const geometry = new THREE.BufferGeometry();
geometryRef.current = geometry;
const material = new THREE.PointsMaterial({
size: 0.035,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
opacity: 0.96,
depthWrite: true,
});
materialRef.current = material;
scene.add(new THREE.Points(geometry, material));
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
const gridMaterials = Array.isArray(grid.material)
? grid.material
: [grid.material];
gridMaterials.forEach((gridMaterial) => {
gridMaterial.transparent = true;
gridMaterial.opacity = 0.3;
});
scene.add(grid);
const axes = new THREE.AxesHelper(0.8);
axes.position.set(-0.05, 0.02, -0.05);
scene.add(axes);
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const observer = new ResizeObserver(resize);
observer.observe(host);
resize();
let animationFrame = 0;
const render = () => {
animationFrame = window.requestAnimationFrame(render);
controls.update();
renderer.render(scene, camera);
};
render();
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
controls.dispose();
geometry.dispose();
material.dispose();
grid.geometry.dispose();
gridMaterials.forEach((gridMaterial) => gridMaterial.dispose());
axes.geometry.dispose();
const axesMaterials = Array.isArray(axes.material)
? axes.material
: [axes.material];
axesMaterials.forEach((axesMaterial) => axesMaterial.dispose());
renderer.dispose();
renderer.domElement.remove();
geometryRef.current = null;
materialRef.current = null;
cameraRef.current = null;
controlsRef.current = null;
};
}, []);
useEffect(() => {
const geometry = geometryRef.current;
const material = materialRef.current;
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!geometry || !material || !camera || !controls) return;
const positions = new Float32Array(frame.pointCount * 3);
let minimumX = Number.POSITIVE_INFINITY;
let maximumX = Number.NEGATIVE_INFINITY;
let minimumY = Number.POSITIVE_INFINITY;
let maximumY = Number.NEGATIVE_INFINITY;
let minimumZ = Number.POSITIVE_INFINITY;
let maximumZ = Number.NEGATIVE_INFINITY;
frame.pointsXyzM.forEach(([x, y, z]) => {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
});
const centerX = (minimumX + maximumX) / 2;
const centerY = (minimumY + maximumY) / 2;
frame.pointsXyzM.forEach(([x, y, z], index) => {
const offset = index * 3;
positions[offset] = x - centerX;
positions[offset + 1] = z - minimumZ;
positions[offset + 2] = -(y - centerY);
});
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.computeBoundingSphere();
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15);
const distance = Math.max(radius * 1.8, 1.2);
controls.target.set(0, targetHeight, 0);
camera.position.set(distance, distance * 0.72, distance);
camera.near = Math.max(distance / 1_000, 0.005);
camera.far = Math.max(distance * 100, 100);
camera.updateProjectionMatrix();
controls.update();
}, [frame]);
useEffect(() => {
const geometry = geometryRef.current;
if (!geometry) return;
geometry.setAttribute(
"color",
new THREE.BufferAttribute(frameColors(frame, mode), 3),
);
geometry.attributes.color.needsUpdate = true;
}, [frame, mode]);
const resetCamera = () => {
const geometry = geometryRef.current;
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!geometry || !camera || !controls) return;
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
const distance = Math.max(radius * 1.8, 1.2);
camera.position.set(distance, distance * 0.72, distance);
controls.target.set(0, radius * 0.18, 0);
controls.update();
};
return (
<div className="lidar-ground-scene" data-testid="lidar-ground-scene">
<div ref={hostRef} className="lidar-ground-scene__viewport">
{renderError ? (
<p className="lidar-ground-scene__error">{renderError}</p>
) : null}
</div>
<div className="lidar-ground-scene__toolbar">
<button type="button" onClick={resetCamera}>Сбросить ракурс</button>
<span>ЛКМ · вращение</span>
<span>Колесо · масштаб</span>
<span>ПКМ · панорама</span>
</div>
</div>
);
}
@@ -6,16 +6,22 @@ import {
} from "@nodedc/ui-react";
import {
fetchLidarGroundFrame,
fetchLidarGroundBenchmarks,
fetchLidarReplayCatalog,
fetchLidarReplayDetail,
type LidarGroundBenchmark,
type LidarGroundFrame,
type LidarReplayCatalog,
type LidarReplayDetail,
type LidarStageReadiness,
} from "../core/lidar/replayQuality";
import { MetricCard } from "../components/MetricCard";
import type { WorkspaceDefinition } from "../productModel";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
@@ -57,6 +63,12 @@ export function LidarQualityWorkspace({
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
const [groundBenchmark, setGroundBenchmark] =
useState<LidarGroundBenchmark | null>(null);
const [groundFrame, setGroundFrame] = useState<LidarGroundFrame | null>(null);
const [groundFrameIndex, setGroundFrameIndex] = useState(0);
const [groundFrameLoading, setGroundFrameLoading] = useState(false);
const [groundFrameError, setGroundFrameError] = useState<string | null>(null);
const [groundViewMode, setGroundViewMode] =
useState<LidarGroundViewMode>("disagreement");
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -77,6 +89,7 @@ export function LidarQualityWorkspace({
if (!target) {
setDetail(null);
setGroundBenchmark(null);
setGroundFrame(null);
return;
}
const [nextDetail, groundCatalog] = await Promise.all([
@@ -91,10 +104,12 @@ export function LidarQualityWorkspace({
setSelectedPackId(target);
setDetail(nextDetail);
setGroundBenchmark(groundCatalog.items[0] ?? null);
setGroundFrameIndex(0);
} catch (loadError) {
if (controller.signal.aborted) return;
setDetail(null);
setGroundBenchmark(null);
setGroundFrame(null);
setError(errorMessage(loadError));
} finally {
if (!controller.signal.aborted) setLoading(false);
@@ -103,6 +118,36 @@ export function LidarQualityWorkspace({
return () => controller.abort();
}, [reloadGeneration, selectedPackId]);
useEffect(() => {
if (!groundBenchmark) {
setGroundFrame(null);
setGroundFrameError(null);
return;
}
const controller = new AbortController();
setGroundFrameLoading(true);
setGroundFrameError(null);
void fetchLidarGroundFrame(
groundBenchmark.benchmarkId,
groundFrameIndex,
{ signal: controller.signal },
)
.then((frame) => {
if (!controller.signal.aborted) setGroundFrame(frame);
})
.catch((loadError) => {
if (controller.signal.aborted) return;
setGroundFrame(null);
setGroundFrameError(errorMessage(loadError));
})
.finally(() => {
if (!controller.signal.aborted) setGroundFrameLoading(false);
});
return () => controller.abort();
}, [groundBenchmark, groundFrameIndex]);
const groundNormalization = groundBenchmark?.inputDomain.normalization ?? null;
return (
<div className="standard-workspace lidar-quality-workspace">
<section className="workspace-lead workspace-lead--compact">
@@ -306,28 +351,144 @@ export function LidarQualityWorkspace({
</small>
</div>
</section>
<section
className="lidar-ground-review"
aria-label="Визуальное сравнение ground segmentation"
>
<header>
<div>
<span className="section-eyebrow">
POINT-ALIGNED REVIEW
</span>
<h3>Покадровое облако и маски</h3>
<p>
Один и тот же map-frame XYZ, разные диагностические
раскраски. Маски не изменяют replay.
</p>
</div>
<div className="lidar-ground-frame-status">
<strong>
Кадр {groundFrameIndex + 1} / {groundBenchmark.frames}
</strong>
<span>
{groundFrame
? `${groundFrame.pointCount.toLocaleString("ru-RU")} точек`
: groundFrameLoading
? "Загрузка…"
: "Нет данных"}
</span>
</div>
</header>
<div className="lidar-ground-review__controls">
<div
className="lidar-ground-modes"
role="group"
aria-label="Режим окраски LiDAR"
>
{([
["intensity", "Интенсивность"],
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Расхождения"],
] as const).map(([mode, label]) => (
<button
type="button"
key={mode}
data-active={groundViewMode === mode ? "true" : undefined}
onClick={() => setGroundViewMode(mode)}
>
{label}
</button>
))}
</div>
<div className="lidar-ground-frame-control">
<button
type="button"
aria-label="Предыдущий LiDAR кадр"
disabled={groundFrameIndex === 0}
onClick={() =>
setGroundFrameIndex((value) => Math.max(0, value - 1))
}
>
</button>
<input
type="range"
aria-label="Номер LiDAR кадра"
min={0}
max={Math.max(groundBenchmark.frames - 1, 0)}
step={1}
value={groundFrameIndex}
onChange={(event) =>
setGroundFrameIndex(Number(event.currentTarget.value))
}
/>
<button
type="button"
aria-label="Следующий LiDAR кадр"
disabled={
groundFrameIndex >= groundBenchmark.frames - 1
}
onClick={() =>
setGroundFrameIndex((value) =>
Math.min(groundBenchmark.frames - 1, value + 1)
)
}
>
+
</button>
</div>
</div>
{groundFrame ? (
<LidarGroundPointCloud
frame={groundFrame}
mode={groundViewMode}
/>
) : (
<div className="lidar-ground-scene-placeholder">
<StatusBadge tone={groundFrameError ? "danger" : "accent"}>
{groundFrameError ? "Frame недоступен" : "Читаем frame"}
</StatusBadge>
<p>{groundFrameError ?? "Проверяем point alignment и masks."}</p>
</div>
)}
<div className="lidar-ground-legend">
<span><i data-color="shared" />Оба считают ground</span>
<span><i data-color="current" />Только current</span>
<span><i data-color="candidate" />Только Patchwork++</span>
<span><i data-color="non-ground" />Оба non-ground</span>
</div>
</section>
<div className="lidar-ground-gates">
<div>
<StatusBadge tone="danger">
Входной контракт не принят
</StatusBadge>
<p>
Patchwork++ ожидает sensor-centric scan и физическую высоту
сенсора; текущий point feed является vendor-mapped increment.
Firmware 3.0.2 подтверждает внутренний MID-360 raw feed,
но текущий MQTT остаётся прореженным LIO/map-продуктом.
</p>
</div>
<div>
<StatusBadge tone="warning">
{groundNormalization?.heightEvidence === "operator-estimated"
? "Высота применена диагностически"
: "Высота не принята"}
</StatusBadge>
<p>
{groundNormalization?.heightEvidence === "operator-estimated"
? `Ручной замер ${formatNumber(
groundNormalization.sensorHeightM,
2,
)} м сдвигает optical origin, но не заменяет runtime calibration.`
: "Нужна привязка optical origin к map и штатной установке."}
</p>
</div>
<div>
<StatusBadge tone="warning">Разметка не принята</StatusBadge>
<p>
IoU, curb recall, low-obstacle recall и reflection-noise
rejection появятся только после независимого human review.
</p>
</div>
<div>
<StatusBadge tone="danger">Не продвигать</StatusBadge>
<p>
Следующий gate: human-reviewed annotation subset или
принятый raw sensor scan с физической высотой сенсора.
Ground IoU и recall появятся только после human review;
визуальное расхождение само по себе не является accuracy.
</p>
</div>
</div>