feat(lidar): add RAVNOVES field review

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 10:26:07 +03:00
parent 2dfb34ef21
commit 3333e9ac0f
13 changed files with 2775 additions and 14 deletions
@@ -2,16 +2,27 @@ 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";
export interface LidarGroundPointCloudFrame {
pointCount: number;
pointsXyzM: Array<[number, number, number]>;
intensity0To255: number[] | null;
masks: {
currentGround: number[];
currentAssigned: number[];
candidateGround: number[];
candidateAssigned: number[];
disagreement: number[];
};
}
interface LidarGroundPointCloudProps {
frame: LidarGroundFrame;
frame: LidarGroundPointCloudFrame;
mode: LidarGroundViewMode;
}
@@ -28,7 +39,7 @@ function setRgb(
}
function frameColors(
frame: LidarGroundFrame,
frame: LidarGroundPointCloudFrame,
mode: LidarGroundViewMode,
): Float32Array {
const colors = new Float32Array(frame.pointCount * 3);
@@ -38,7 +49,7 @@ function frameColors(
const candidate = frame.masks.candidateGround[index] === 1;
const candidateAssigned = frame.masks.candidateAssigned[index] === 1;
if (mode === "intensity") {
const intensity = frame.intensity0To255[index] / 255;
const intensity = (frame.intensity0To255?.[index] ?? 96) / 255;
setRgb(
colors,
offset,
@@ -88,6 +99,8 @@ export function LidarGroundPointCloud({
const materialRef = useRef<THREE.PointsMaterial | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const fogRef = useRef<THREE.FogExp2 | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
@@ -115,7 +128,9 @@ export function LidarGroundPointCloud({
host.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x071018, 0.035);
const fog = new THREE.FogExp2(0x071018, 0.035);
scene.fog = fog;
fogRef.current = fog;
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 1_000);
camera.position.set(6, 4.5, 6);
cameraRef.current = camera;
@@ -147,6 +162,7 @@ export function LidarGroundPointCloud({
scene.add(new THREE.Points(geometry, material));
const grid = new THREE.GridHelper(24, 48, 0x3c7cff, 0x233747);
gridRef.current = grid;
const gridMaterials = Array.isArray(grid.material)
? grid.material
: [grid.material];
@@ -198,6 +214,8 @@ export function LidarGroundPointCloud({
materialRef.current = null;
cameraRef.current = null;
controlsRef.current = null;
fogRef.current = null;
gridRef.current = null;
};
}, []);
@@ -206,7 +224,9 @@ export function LidarGroundPointCloud({
const material = materialRef.current;
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!geometry || !material || !camera || !controls) return;
const fog = fogRef.current;
const grid = gridRef.current;
if (!geometry || !material || !camera || !controls || !fog || !grid) return;
const positions = new Float32Array(frame.pointCount * 3);
let minimumX = Number.POSITIVE_INFINITY;
@@ -235,6 +255,8 @@ export function LidarGroundPointCloud({
geometry.computeBoundingSphere();
const radius = Math.max(geometry.boundingSphere?.radius ?? 1, 0.2);
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035);
grid.scale.setScalar(Math.max(radius / 12, 1));
const targetHeight = Math.max((maximumZ - minimumZ) * 0.35, 0.15);
const distance = Math.max(radius * 1.8, 1.2);
@@ -6,10 +6,14 @@ import {
} from "@nodedc/ui-react";
import {
fetchLidarFieldReviews,
fetchLidarFieldReviewWindow,
fetchLidarGroundFrame,
fetchLidarGroundBenchmarks,
fetchLidarReplayCatalog,
fetchLidarReplayDetail,
type LidarFieldReview,
type LidarFieldReviewWindow,
type LidarGroundBenchmark,
type LidarGroundFrame,
type LidarReplayCatalog,
@@ -69,6 +73,14 @@ export function LidarQualityWorkspace({
const [groundFrameError, setGroundFrameError] = useState<string | null>(null);
const [groundViewMode, setGroundViewMode] =
useState<LidarGroundViewMode>("disagreement");
const [fieldReview, setFieldReview] = useState<LidarFieldReview | null>(null);
const [fieldWindow, setFieldWindow] =
useState<LidarFieldReviewWindow | null>(null);
const [fieldWindowIndex, setFieldWindowIndex] = useState(0);
const [fieldLoading, setFieldLoading] = useState(true);
const [fieldError, setFieldError] = useState<string | null>(null);
const [fieldViewMode, setFieldViewMode] =
useState<LidarGroundViewMode>("disagreement");
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -146,7 +158,56 @@ export function LidarQualityWorkspace({
return () => controller.abort();
}, [groundBenchmark, groundFrameIndex]);
useEffect(() => {
const controller = new AbortController();
setFieldLoading(true);
setFieldError(null);
void fetchLidarFieldReviews({ signal: controller.signal })
.then((nextCatalog) => {
if (controller.signal.aborted) return;
const nextReview = nextCatalog.items[0] ?? null;
setFieldReview(nextReview);
setFieldWindow(null);
setFieldWindowIndex(nextReview?.selection.defaultWindowIndex ?? 0);
})
.catch((loadError) => {
if (controller.signal.aborted) return;
setFieldReview(null);
setFieldWindow(null);
setFieldError(errorMessage(loadError));
})
.finally(() => {
if (!controller.signal.aborted) setFieldLoading(false);
});
return () => controller.abort();
}, [reloadGeneration]);
useEffect(() => {
if (!fieldReview) return;
const controller = new AbortController();
setFieldLoading(true);
setFieldError(null);
void fetchLidarFieldReviewWindow(
fieldReview.reviewId,
fieldWindowIndex,
{ signal: controller.signal },
)
.then((window) => {
if (!controller.signal.aborted) setFieldWindow(window);
})
.catch((loadError) => {
if (controller.signal.aborted) return;
setFieldWindow(null);
setFieldError(errorMessage(loadError));
})
.finally(() => {
if (!controller.signal.aborted) setFieldLoading(false);
});
return () => controller.abort();
}, [fieldReview, fieldWindowIndex]);
const groundNormalization = groundBenchmark?.inputDomain.normalization ?? null;
const selectedFieldWindow = fieldReview?.windows[fieldWindowIndex] ?? null;
return (
<div className="standard-workspace lidar-quality-workspace">
@@ -191,12 +252,213 @@ export function LidarQualityWorkspace({
</GlassSurface>
) : (
<>
<GlassSurface className="lidar-field-review" padding="lg">
<header className="panel-heading lidar-field-review__heading">
<div>
<span className="section-eyebrow">ПОЛЕВОЙ REVIEW · RAVNOVES00</span>
<h2>
{fieldReview?.displayName
?? "Центральный городской интервал"}
</h2>
<p>
Дорога, дома, автомобили и растительность. Облако накоплено
по исходным LiDAR-сэмплам выбранного окна, а кадр камеры
фиксирует контекст сцены.
</p>
</div>
<StatusBadge tone={fieldError ? "danger" : "warning"}>
{fieldError ? "Review недоступен" : "Visual review only"}
</StatusBadge>
</header>
{fieldReview ? (
<>
<div
className="lidar-field-review__source"
aria-label="Источник полевого LiDAR review"
>
<div>
<span>Запись</span>
<strong>{fieldReview.sessionId}</strong>
</div>
<div>
<span>Интервал записи</span>
<strong>
{formatNumber(fieldReview.source.timelineStartSeconds, 2)}
{""}
{formatNumber(fieldReview.source.timelineEndSeconds, 2)} с
</strong>
</div>
<div>
<span>LiDAR-сэмплов</span>
<strong>
{fieldReview.source.availableLidarFrames.toLocaleString("ru-RU")}
</strong>
</div>
<div>
<span>Исходных точек</span>
<strong>
{fieldReview.source.pointCount.toLocaleString("ru-RU")}
</strong>
</div>
</div>
<div
className="lidar-field-window-list"
role="group"
aria-label="Полевые сцены RAVNOVES00"
>
{fieldReview.windows.map((window) => (
<button
type="button"
key={window.key}
data-selected={
window.index === fieldWindowIndex ? "true" : undefined
}
onClick={() => {
setFieldWindow(null);
setFieldWindowIndex(window.index);
}}
>
<span>{window.index + 1}</span>
<div>
<strong>{window.label}</strong>
<small>
{formatNumber(window.startSeconds, 0)}
{""}
{formatNumber(window.endSeconds, 0)} с ·{" "}
{window.sourceLidarSamples} сканов
</small>
</div>
</button>
))}
</div>
<section className="lidar-field-stage">
<div className="lidar-field-camera">
<div className="lidar-field-stage__label">
<span>КОНТЕКСТ КАМЕРЫ</span>
<strong>
{selectedFieldWindow?.label ?? "Выбранная сцена"}
</strong>
</div>
{fieldWindow ? (
<img
src={fieldWindow.previewUrl}
alt={`Кадр камеры: ${fieldWindow.window.label}`}
/>
) : (
<div className="lidar-field-stage__pending">
{fieldError ?? "Загружаем кадр выбранной сцены…"}
</div>
)}
<footer>
<span>
Кадр {selectedFieldWindow?.previewSourceFrameIndex ?? "—"}
</span>
<span>
t = {formatNumber(
selectedFieldWindow?.previewSessionSeconds ?? null,
2,
)}{" "}
с
</span>
</footer>
</div>
<div className="lidar-field-cloud">
<header>
<div className="lidar-field-stage__label">
<span>НАКОПЛЕННОЕ MAP-ОБЛАКО</span>
<strong>
{selectedFieldWindow
? `${selectedFieldWindow.sourcePointCount.toLocaleString(
"ru-RU",
)} исходных · ${selectedFieldWindow.displayPointCount.toLocaleString(
"ru-RU",
)} показано`
: "Ожидание данных"}
</strong>
</div>
<div
className="lidar-ground-modes"
role="group"
aria-label="Режим окраски полевого LiDAR"
>
{([
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Расхождения"],
] as const).map(([mode, label]) => (
<button
type="button"
key={mode}
data-active={
fieldViewMode === mode ? "true" : undefined
}
onClick={() => setFieldViewMode(mode)}
>
{label}
</button>
))}
</div>
</header>
{fieldWindow ? (
<LidarGroundPointCloud
frame={fieldWindow}
mode={fieldViewMode}
/>
) : (
<div className="lidar-ground-scene-placeholder">
<StatusBadge tone={fieldError ? "danger" : "accent"}>
{fieldError ? "Ошибка" : "Загрузка"}
</StatusBadge>
<span>
{fieldError ?? "Готовим накопленное облако…"}
</span>
</div>
)}
</div>
</section>
<div className="lidar-field-review__explanation">
<StatusBadge tone="warning">Не accuracy</StatusBadge>
<p>
Маски вычислены отдельно на каждом исходном скане и только
затем сведены в map frame. Это legacy E10 vendor-map
derivative без intensity и независимой ground-разметки,
поэтому результат предназначен для визуального разбора, а
не для production-gate.
</p>
<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>
</div>
</>
) : (
<div className="lidar-field-review__empty">
<StatusBadge tone={fieldError ? "danger" : "accent"}>
{fieldError ? "Ошибка загрузки" : "Проверка evidence"}
</StatusBadge>
<p>
{fieldError
?? (fieldLoading
? "Читаем RAVNOVES00 field-review артефакт…"
: "Field-review артефакт ещё не опубликован.")}
</p>
</div>
)}
</GlassSurface>
<section className="metrics-grid" aria-label="Качество LiDAR replay">
<MetricCard
featured
eyebrow="ТОЧЕЧНЫХ КАДРОВ"
eyebrow="ТЕХНИЧЕСКИХ КАДРОВ"
value={detail.pack.pointFrames.toLocaleString("ru-RU")}
detail={`${detail.pack.points.toLocaleString("ru-RU")} точек сохранено`}
detail={`${detail.pack.points.toLocaleString("ru-RU")} точек · indoor contract slice`}
/>
<MetricCard
eyebrow="СРЕДНЕЕ ТОЧЕК"
@@ -358,12 +620,13 @@ export function LidarQualityWorkspace({
<header>
<div>
<span className="section-eyebrow">
POINT-ALIGNED REVIEW
ТЕХНИЧЕСКИЙ CONTRACT SLICE · VIEWER_LIVE
</span>
<h3>Покадровое облако и маски</h3>
<h3>Indoor-проверка point-aligned контракта</h3>
<p>
Один и тот же map-frame XYZ, разные диагностические
раскраски. Маски не изменяют replay.
Этот короткий indoor-срез подтверждает выравнивание XYZ
и масок, но не является полевой оценкой качества. Для
улицы используйте RAVNOVES00 выше.
</p>
</div>
<div className="lidar-ground-frame-status">