feat(lidar): add point-aligned ground review
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user