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