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
@@ -77,6 +77,11 @@ export interface LidarGroundBenchmark {
physicalSensorHeightKnown: boolean;
sensorScanGeometryKnown: boolean;
reason: string;
normalization: {
sensorHeightM: number;
mapVerticalOriginOffsetM: number;
heightEvidence: "missing" | "operator-estimated" | "runtime-calibrated";
} | null;
};
labels: {
status: "missing-independent-review";
@@ -115,6 +120,33 @@ export interface LidarGroundBenchmarkCatalog {
items: LidarGroundBenchmark[];
}
export interface LidarGroundFrame {
benchmarkId: string;
replayPackId: string;
sessionId: string;
frameIndex: number;
frameCount: number;
captureSequence: number;
pointCount: number;
coordinateFrame: "map";
distanceUnit: "m";
pointsXyzM: Array<[number, number, number]>;
intensity0To255: number[];
masks: {
currentGround: number[];
currentAssigned: number[];
candidateGround: number[];
candidateAssigned: number[];
disagreement: number[];
};
counts: {
currentGround: number;
candidateGround: number;
disagreement: number;
};
groundTruth: false;
}
export class LidarReplayContractError extends Error {}
export class LidarReplayApiError extends Error {
@@ -178,6 +210,20 @@ function boolean(value: unknown, label: string): boolean {
return value;
}
function groundMask(value: unknown, label: string, count: number): number[] {
const values = array(value, label);
if (values.length !== count) {
throw new LidarReplayContractError(`${label}: длина маски не совпадает`);
}
return values.map((item, index) => {
const parsed = integer(item, `${label}[${index}]`);
if (parsed !== 0 && parsed !== 1) {
throw new LidarReplayContractError(`${label}: ожидалась бинарная маска`);
}
return parsed;
});
}
function distribution(value: unknown, label: string): LidarDistribution {
const source = record(value, label);
return {
@@ -355,6 +401,9 @@ function groundBenchmark(value: unknown): LidarGroundBenchmark {
throw new LidarReplayContractError("Ground benchmark status несовместим");
}
const inputDomain = record(source.input_domain, "input_domain");
const normalization = inputDomain.normalization === undefined
? null
: record(inputDomain.normalization, "input_domain.normalization");
const labels = record(source.labels, "labels");
const comparison = record(source.comparison, "comparison");
const decision = record(source.decision, "decision");
@@ -397,6 +446,33 @@ function groundBenchmark(value: unknown): LidarGroundBenchmark {
"input_domain.sensor_scan_geometry_known",
),
reason: string(inputDomain.reason, "input_domain.reason"),
normalization: normalization
? {
sensorHeightM:
number(
normalization.sensor_height_m,
"normalization.sensor_height_m",
) ?? 0,
mapVerticalOriginOffsetM:
number(
normalization.map_vertical_origin_offset_m,
"normalization.map_vertical_origin_offset_m",
) ?? 0,
heightEvidence: (() => {
const value = normalization.height_evidence;
if (
value !== "missing"
&& value !== "operator-estimated"
&& value !== "runtime-calibrated"
) {
throw new LidarReplayContractError(
"normalization.height_evidence: неизвестное значение",
);
}
return value;
})(),
}
: null,
},
labels: {
status: "missing-independent-review",
@@ -449,6 +525,122 @@ export function parseLidarGroundBenchmarkCatalog(
};
}
export function parseLidarGroundFrame(value: unknown): LidarGroundFrame {
const source = record(value, "LiDAR ground frame");
if (
source.schema_version !== "missioncore.lidar-ground-frame/v1"
|| source.access !== "read-only"
|| source.ground_truth !== false
|| source.coordinate_frame !== "map"
|| source.distance_unit !== "m"
) {
throw new LidarReplayContractError("LiDAR ground frame contract несовместим");
}
const pointCount = integer(source.point_count, "point_count");
if (pointCount < 1 || pointCount > 200_000) {
throw new LidarReplayContractError("LiDAR ground frame слишком большой");
}
const points = array(source.points_xyz_m, "points_xyz_m");
if (points.length !== pointCount) {
throw new LidarReplayContractError("Количество LiDAR points не совпадает");
}
const pointsXyzM = points.map((value, index): [number, number, number] => {
const tuple = array(value, `points_xyz_m[${index}]`);
if (tuple.length !== 3) {
throw new LidarReplayContractError("LiDAR point должен содержать XYZ");
}
return [
number(tuple[0], `points_xyz_m[${index}].x`) ?? 0,
number(tuple[1], `points_xyz_m[${index}].y`) ?? 0,
number(tuple[2], `points_xyz_m[${index}].z`) ?? 0,
];
});
const intensity = array(source.intensity_0_255, "intensity_0_255");
if (intensity.length !== pointCount) {
throw new LidarReplayContractError("Количество intensity не совпадает");
}
const intensity0To255 = intensity.map((value, index) => {
const parsed = integer(value, `intensity_0_255[${index}]`);
if (parsed > 255) {
throw new LidarReplayContractError("LiDAR intensity вне диапазона");
}
return parsed;
});
const masks = record(source.masks, "masks");
const counts = record(source.counts, "counts");
const currentGround = groundMask(
masks.current_ground,
"masks.current_ground",
pointCount,
);
const candidateGround = groundMask(
masks.candidate_ground,
"masks.candidate_ground",
pointCount,
);
const disagreement = groundMask(
masks.disagreement,
"masks.disagreement",
pointCount,
);
const parsedCounts = {
currentGround: integer(counts.current_ground, "counts.current_ground"),
candidateGround: integer(
counts.candidate_ground,
"counts.candidate_ground",
),
disagreement: integer(counts.disagreement, "counts.disagreement"),
};
const frameIndex = integer(source.frame_index, "frame_index");
const frameCount = integer(source.frame_count, "frame_count");
if (
frameCount < 1
|| frameIndex >= frameCount
|| parsedCounts.currentGround !== currentGround.reduce((sum, item) => sum + item, 0)
|| parsedCounts.candidateGround !== candidateGround.reduce((sum, item) => sum + item, 0)
|| parsedCounts.disagreement !== disagreement.reduce((sum, item) => sum + item, 0)
|| disagreement.some(
(item, index) => item !== Number(currentGround[index] !== candidateGround[index]),
)
) {
throw new LidarReplayContractError("LiDAR ground frame несовместим");
}
return {
benchmarkId: string(
source.benchmark_id,
"benchmark_id",
SAFE_GROUND_BENCHMARK_ID,
),
replayPackId: string(source.replay_pack_id, "replay_pack_id", SAFE_PACK_ID),
sessionId: string(source.session_id, "session_id", SAFE_ID),
frameIndex,
frameCount,
captureSequence: integer(source.capture_sequence, "capture_sequence"),
pointCount,
coordinateFrame: "map",
distanceUnit: "m",
pointsXyzM,
intensity0To255,
masks: {
currentGround,
currentAssigned: groundMask(
masks.current_assigned,
"masks.current_assigned",
pointCount,
),
candidateGround,
candidateAssigned: groundMask(
masks.candidate_assigned,
"masks.candidate_assigned",
pointCount,
),
disagreement,
},
counts: parsedCounts,
groundTruth: false,
};
}
async function responseJson(
response: Response,
fallback: string,
@@ -521,3 +713,29 @@ export async function fetchLidarGroundBenchmarks(
await responseJson(response, "Не удалось получить LiDAR ground benchmark."),
);
}
export async function fetchLidarGroundFrame(
benchmarkId: string,
frameIndex: number,
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
): Promise<LidarGroundFrame> {
if (
!SAFE_GROUND_BENCHMARK_ID.test(benchmarkId)
|| !Number.isInteger(frameIndex)
|| frameIndex < 0
) {
throw new LidarReplayContractError("Некорректный LiDAR ground frame");
}
const fetcher = options.fetcher ?? fetch;
const response = await fetcher(
`/api/v1/lidar/ground-benchmarks/${benchmarkId}/frames/${frameIndex}`,
{
method: "GET",
headers: { Accept: "application/json" },
signal: options.signal,
},
);
return parseLidarGroundFrame(
await responseJson(response, "Не удалось получить LiDAR ground frame."),
);
}
@@ -171,6 +171,37 @@
grid-template-columns: 1fr;
}
.lidar-ground-review > header,
.lidar-ground-review__controls {
align-items: stretch;
flex-direction: column;
}
.lidar-ground-frame-status {
justify-items: start;
text-align: left;
}
.lidar-ground-modes {
flex-wrap: wrap;
}
.lidar-ground-frame-control input {
width: 100%;
}
.lidar-ground-frame-control {
width: 100%;
}
.lidar-ground-scene {
min-height: 22rem;
}
.lidar-ground-scene__toolbar span {
display: none;
}
.polygon-run-identity dl {
grid-template-columns: 1fr;
}
@@ -1020,6 +1020,209 @@
padding-top: 0.7rem;
}
.lidar-ground-review {
display: grid;
gap: 0.75rem;
margin-top: 0.85rem;
border: 1px solid rgb(74 215 255 / 0.18);
border-radius: 1rem;
background:
radial-gradient(circle at 18% 0%, rgb(56 124 255 / 0.12), transparent 34%),
rgb(4 12 19 / 0.72);
padding: 0.85rem;
}
.lidar-ground-review > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.lidar-ground-review h3,
.lidar-ground-review p {
margin: 0;
}
.lidar-ground-review h3 {
margin-top: 0.2rem;
color: var(--nodedc-text-primary);
font-size: 0.95rem;
}
.lidar-ground-review p,
.lidar-ground-frame-status span,
.lidar-ground-scene__toolbar,
.lidar-ground-legend {
color: var(--nodedc-text-muted);
font-size: 0.61rem;
line-height: 1.45;
}
.lidar-ground-review > header p {
max-width: 35rem;
margin-top: 0.25rem;
}
.lidar-ground-frame-status {
display: grid;
flex: 0 0 auto;
gap: 0.18rem;
justify-items: end;
text-align: right;
}
.lidar-ground-frame-status strong {
color: var(--nodedc-text-primary);
font-size: 0.7rem;
}
.lidar-ground-review__controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.lidar-ground-modes,
.lidar-ground-frame-control {
display: flex;
align-items: center;
gap: 0.35rem;
}
.lidar-ground-modes button,
.lidar-ground-frame-control button,
.lidar-ground-scene__toolbar button {
border: 1px solid var(--station-hairline);
border-radius: 999px;
background: rgb(255 255 255 / 0.035);
padding: 0.4rem 0.62rem;
color: var(--nodedc-text-secondary);
font: inherit;
font-size: 0.61rem;
cursor: pointer;
}
.lidar-ground-modes button:hover,
.lidar-ground-modes button[data-active="true"],
.lidar-ground-frame-control button:hover:not(:disabled),
.lidar-ground-scene__toolbar button:hover {
border-color: rgb(74 215 255 / 0.48);
background: rgb(74 215 255 / 0.1);
color: var(--nodedc-text-primary);
}
.lidar-ground-frame-control button {
display: grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
padding: 0;
font-size: 0.82rem;
}
.lidar-ground-frame-control button:disabled {
opacity: 0.35;
cursor: default;
}
.lidar-ground-frame-control input {
width: min(16rem, 24vw);
accent-color: #4ad7ff;
}
.lidar-ground-scene {
position: relative;
overflow: hidden;
min-height: 30rem;
border: 1px solid rgb(255 255 255 / 0.09);
border-radius: 0.9rem;
background: #071018;
}
.lidar-ground-scene__viewport {
position: absolute;
inset: 0;
}
.lidar-ground-scene__viewport canvas {
display: block;
width: 100%;
height: 100%;
}
.lidar-ground-scene__toolbar {
position: absolute;
z-index: 2;
right: 0.6rem;
bottom: 0.6rem;
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 999px;
background: rgb(5 13 21 / 0.82);
padding: 0.28rem;
backdrop-filter: blur(14px);
}
.lidar-ground-scene__toolbar button {
background: rgb(74 215 255 / 0.08);
}
.lidar-ground-scene__error {
position: absolute;
inset: 0;
display: grid;
place-items: center;
margin: 0;
color: var(--nodedc-danger);
}
.lidar-ground-scene-placeholder {
display: grid;
min-height: 18rem;
place-content: center;
justify-items: center;
gap: 0.55rem;
border: 1px solid var(--station-hairline);
border-radius: 0.9rem;
background: #071018;
text-align: center;
}
.lidar-ground-legend {
display: flex;
flex-wrap: wrap;
gap: 0.45rem 0.85rem;
}
.lidar-ground-legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.lidar-ground-legend i {
width: 0.46rem;
height: 0.46rem;
border-radius: 50%;
background: #3d4a58;
}
.lidar-ground-legend i[data-color="shared"] {
background: #b9ff4a;
}
.lidar-ground-legend i[data-color="current"] {
background: #ffa32e;
}
.lidar-ground-legend i[data-color="candidate"] {
background: #3dd7ff;
}
.lidar-ground-empty {
margin-top: 1rem;
}
@@ -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>
@@ -7,9 +7,11 @@ let server;
let parseLidarReplayCatalog;
let parseLidarReplayDetail;
let parseLidarGroundBenchmarkCatalog;
let parseLidarGroundFrame;
let fetchLidarReplayCatalog;
let fetchLidarReplayDetail;
let fetchLidarGroundBenchmarks;
let fetchLidarGroundFrame;
let LidarReplayContractError;
let workspaceById;
@@ -23,9 +25,11 @@ before(async () => {
parseLidarReplayCatalog,
parseLidarReplayDetail,
parseLidarGroundBenchmarkCatalog,
parseLidarGroundFrame,
fetchLidarReplayCatalog,
fetchLidarReplayDetail,
fetchLidarGroundBenchmarks,
fetchLidarGroundFrame,
LidarReplayContractError,
} = await server.ssrLoadModule("/src/core/lidar/replayQuality.ts"));
({ workspaceById } = await server.ssrLoadModule("/src/productModel.ts"));
@@ -176,6 +180,11 @@ function groundCatalog(overrides = {}) {
representation: "vendor-map-increment",
physical_sensor_height_known: false,
sensor_scan_geometry_known: false,
normalization: {
sensor_height_m: 1.27,
map_vertical_origin_offset_m: 1.27,
height_evidence: "operator-estimated",
},
reason: "Patchwork++ expects sensor-centric scans.",
},
labels: {
@@ -218,6 +227,46 @@ function groundCatalog(overrides = {}) {
};
}
function groundFrame(overrides = {}) {
return {
schema_version: "missioncore.lidar-ground-frame/v1",
benchmark_id: `ground-benchmark-${"c".repeat(64)}`,
replay_pack_id: packId,
session_id: "20260719T220917Z_viewer_live",
frame_index: 0,
frame_count: 2,
capture_sequence: 42,
point_count: 3,
coordinate_frame: "map",
distance_unit: "m",
points_xyz_m: [
[0, 0, 0],
[1, 0, 0.1],
[0, 1, 0.5],
],
intensity_0_255: [10, 120, 255],
masks: {
current_ground: [1, 1, 0],
current_assigned: [1, 1, 1],
candidate_ground: [1, 0, 0],
candidate_assigned: [1, 1, 1],
disagreement: [0, 1, 0],
},
counts: {
current_ground: 2,
candidate_ground: 1,
disagreement: 1,
},
access: "read-only",
ground_truth: false,
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
},
...overrides,
};
}
function jsonResponse(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
@@ -252,6 +301,10 @@ test("ground benchmark stays diagnostic until input and labels are accepted", ()
assert.equal(parsed.items[0].inputDomain.accepted, false);
assert.equal(parsed.items[0].labels.metricsAvailable, false);
assert.equal(parsed.items[0].candidate.groundFraction.p50, 0.005);
assert.equal(
parsed.items[0].inputDomain.normalization.heightEvidence,
"operator-estimated",
);
assert.equal(parsed.items[0].decision.productionPromotion, false);
const promoted = groundCatalog();
@@ -262,12 +315,58 @@ test("ground benchmark stays diagnostic until input and labels are accepted", ()
);
});
test("ground frame stays point-aligned, bounded and path-free", () => {
const parsed = parseLidarGroundFrame(groundFrame());
assert.equal(parsed.pointCount, 3);
assert.deepEqual(parsed.pointsXyzM[1], [1, 0, 0.1]);
assert.deepEqual(parsed.masks.disagreement, [0, 1, 0]);
assert.equal(parsed.counts.currentGround, 2);
assert.equal("path" in parsed, false);
assert.throws(
() => parseLidarGroundFrame(groundFrame({
masks: {
...groundFrame().masks,
disagreement: [0, 1],
},
})),
LidarReplayContractError,
);
assert.throws(
() => parseLidarGroundFrame(groundFrame({
counts: {
current_ground: 1,
candidate_ground: 1,
disagreement: 1,
},
})),
LidarReplayContractError,
);
assert.throws(
() => parseLidarGroundFrame(groundFrame({
masks: {
...groundFrame().masks,
disagreement: [0, 0, 0],
},
counts: {
current_ground: 2,
candidate_ground: 1,
disagreement: 0,
},
})),
LidarReplayContractError,
);
});
test("LiDAR fetchers use read-only endpoints and workspace is registered", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
if (String(input).includes("ground-benchmarks")) {
return jsonResponse(groundCatalog());
return String(input).includes("/frames/")
? jsonResponse(groundFrame())
: jsonResponse(groundCatalog());
}
return String(input).includes(packId)
? jsonResponse(detail())
@@ -276,10 +375,16 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
const parsedCatalog = await fetchLidarReplayCatalog({ fetcher });
const parsedDetail = await fetchLidarReplayDetail(packId, { fetcher });
const ground = await fetchLidarGroundBenchmarks(packId, { fetcher });
const frame = await fetchLidarGroundFrame(
`ground-benchmark-${"c".repeat(64)}`,
0,
{ fetcher },
);
assert.equal(parsedCatalog.validTotal, 1);
assert.equal(parsedDetail.pack.packId, packId);
assert.equal(ground.validTotal, 1);
assert.equal(frame.pointCount, 3);
assert.deepEqual(calls, [
{ input: "/api/v1/lidar/replay-packs?limit=50", method: "GET" },
{ input: `/api/v1/lidar/replay-packs/${packId}`, method: "GET" },
@@ -287,6 +392,10 @@ test("LiDAR fetchers use read-only endpoints and workspace is registered", async
input: `/api/v1/lidar/ground-benchmarks?pack_id=${packId}&limit=20`,
method: "GET",
},
{
input: `/api/v1/lidar/ground-benchmarks/ground-benchmark-${"c".repeat(64)}/frames/0`,
method: "GET",
},
]);
assert.equal(workspaceById("lidar-quality").root, "data");
assert.equal(workspaceById("lidar-quality").kind, "lidar-quality");