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