feat(perception): add lossless lidar replay v2
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user