feat(perception): add lossless lidar replay v2

This commit is contained in:
DCCONSTRUCTIONS
2026-07-25 01:35:03 +03:00
parent 3f549f91f1
commit e4fdd9fa20
18 changed files with 2538 additions and 11 deletions
@@ -0,0 +1,269 @@
import { useEffect, useState } from "react";
import {
Button,
GlassSurface,
StatusBadge,
} from "@nodedc/ui-react";
import {
fetchLidarReplayCatalog,
fetchLidarReplayDetail,
type LidarReplayCatalog,
type LidarReplayDetail,
type LidarStageReadiness,
} from "../core/lidar/replayQuality";
import { MetricCard } from "../components/MetricCard";
import type { WorkspaceDefinition } from "../productModel";
function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
}
function formatFraction(value: number | null): string {
return value === null ? "—" : `${(value * 100).toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})}%`;
}
function readinessTone(
readiness: LidarStageReadiness["readiness"],
): "success" | "warning" | "danger" {
if (readiness === "ready") return "success";
if (readiness === "degraded") return "warning";
return "danger";
}
function readinessLabel(readiness: LidarStageReadiness["readiness"]): string {
if (readiness === "ready") return "Готов";
if (readiness === "degraded") return "С ограничениями";
return "Заблокирован";
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось прочитать LiDAR evidence.";
}
export function LidarQualityWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const [catalog, setCatalog] = useState<LidarReplayCatalog | null>(null);
const [detail, setDetail] = useState<LidarReplayDetail | null>(null);
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadGeneration, setReloadGeneration] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
void (async () => {
try {
const nextCatalog = await fetchLidarReplayCatalog({
signal: controller.signal,
});
if (controller.signal.aborted) return;
setCatalog(nextCatalog);
const target = selectedPackId ?? nextCatalog.items[0]?.packId ?? null;
if (!target) {
setDetail(null);
return;
}
const nextDetail = await fetchLidarReplayDetail(target, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
setSelectedPackId(target);
setDetail(nextDetail);
} catch (loadError) {
if (controller.signal.aborted) return;
setDetail(null);
setError(errorMessage(loadError));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, [reloadGeneration, selectedPackId]);
return (
<div className="standard-workspace lidar-quality-workspace">
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>{definition.description}</p>
</div>
<span className="workspace-lead__note">Только проверенные replay-артефакты</span>
</section>
{loading && !detail ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone="accent">Проверка evidence</StatusBadge>
<h2>Читаем LiDAR replay и quality report</h2>
<p>Интерфейс покажет pack только после проверки manifest, hashes и equivalence gate.</p>
</GlassSurface>
) : error ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
<h2>LiDAR quality report не открыт</h2>
<p>{error}</p>
<Button
size="compact"
variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)}
>
Повторить
</Button>
</GlassSurface>
) : !detail ? (
<GlassSurface className="lidar-quality-message" padding="lg">
<StatusBadge tone={catalog?.configured ? "neutral" : "warning"}>
{catalog?.configured ? "Каталог пуст" : "Storage не настроен"}
</StatusBadge>
<h2>Lossless LiDAR replay пока не опубликован</h2>
<p>
После подготовки первого v2 pack здесь появятся field retention, cadence,
intensity, pose coverage и readiness следующих стадий.
</p>
</GlassSurface>
) : (
<>
<section className="metrics-grid" aria-label="Качество LiDAR replay">
<MetricCard
featured
eyebrow="ТОЧЕЧНЫХ КАДРОВ"
value={detail.pack.pointFrames.toLocaleString("ru-RU")}
detail={`${detail.pack.points.toLocaleString("ru-RU")} точек сохранено`}
/>
<MetricCard
eyebrow="СРЕДНЕЕ ТОЧЕК"
value={formatNumber(detail.pack.meanPointsPerFrame, 0)}
detail="На один lio_pcl кадр"
/>
<MetricCard
eyebrow="P95 ИНТЕРВАЛА"
value={formatNumber(detail.pack.p95FrameIntervalMs)}
unit="мс"
detail="По exact host monotonic time"
/>
<MetricCard
eyebrow="POSE COVERAGE"
value={formatFraction(detail.pack.poseCoverageFraction)}
detail={`Порог ${formatNumber(detail.quality.poseBinding.thresholdMs)} мс`}
/>
</section>
<div className="lidar-quality-grid">
<GlassSurface className="lidar-quality-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ЦЕЛОСТНОСТЬ</span>
<h2>Lossless replay v2</h2>
</div>
<StatusBadge tone="success">Equivalence passed</StatusBadge>
</header>
<div className="lidar-quality-facts">
<div>
<span>Сессия</span>
<strong>{detail.pack.sessionId}</strong>
</div>
<div>
<span>Сверено массивов</span>
<strong>{detail.equivalence.arraysCompared}</strong>
</div>
<div>
<span>Несовпадений</span>
<strong>{detail.equivalence.arrayMismatches}</strong>
</div>
<div>
<span>Intensity p50 / p95</span>
<strong>
{formatNumber(detail.quality.intensity.p50)} /{" "}
{formatNumber(detail.quality.intensity.p95)}
</strong>
</div>
</div>
<div className="lidar-retention-list">
{Object.entries(detail.quality.fieldRetention).map(([field, retained]) => (
<span key={field} data-retained={retained ? "true" : "false"}>
<i aria-hidden="true" />
{field.replaceAll("_", " ")}
</span>
))}
</div>
</GlassSurface>
<GlassSurface className="lidar-quality-panel" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">ДОПУСК СТАДИЙ</span>
<h2>Что можно запускать</h2>
</div>
</header>
<div className="lidar-readiness-list">
{detail.stages.map((stage) => (
<div key={stage.stage}>
<div>
<strong>{stage.stage}</strong>
<small>
{stage.reasons.length
? stage.reasons.join(" · ")
: "Входной контракт выполнен"}
</small>
</div>
<StatusBadge tone={readinessTone(stage.readiness)}>
{readinessLabel(stage.readiness)}
</StatusBadge>
</div>
))}
</div>
</GlassSurface>
</div>
<GlassSurface className="lidar-pack-catalog" padding="lg">
<header className="panel-heading">
<div>
<span className="section-eyebrow">REPLAY PACKS</span>
<h2>Проверенные записи</h2>
</div>
<small>
{catalog?.validTotal ?? 0} valid · {catalog?.duplicateTotal ?? 0} superseded ·{" "}
{catalog?.invalidTotal ?? 0} rejected
</small>
</header>
<div className="lidar-pack-list">
{catalog?.items.map((pack) => (
<button
type="button"
key={pack.packId}
data-selected={pack.packId === detail.pack.packId ? "true" : undefined}
onClick={() => setSelectedPackId(pack.packId)}
>
<div>
<strong>{pack.sessionId}</strong>
<small>
{pack.pointFrames.toLocaleString("ru-RU")} кадров ·{" "}
{pack.points.toLocaleString("ru-RU")} точек
</small>
</div>
<StatusBadge tone="success">Passed</StatusBadge>
</button>
))}
</div>
<footer>
{detail.quality.limitations.map((limitation) => (
<span key={limitation}>{limitation}</span>
))}
</footer>
</GlassSurface>
</>
)}
</div>
);
}
@@ -57,6 +57,7 @@ import {
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
import type { SceneSettings } from "../sceneSettings";
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
import { LidarQualityWorkspace } from "./LidarQualityWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
@@ -1306,6 +1307,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
return <MissionWorkspace {...props} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "lidar-quality":
return <LidarQualityWorkspace definition={props.definition} />;
case "polygon-run":
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
case "device":