feat(polygon): add full-frame operator review
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { Button, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchGroundFailurePreview,
|
||||
fetchGroundQualification,
|
||||
type GroundFailurePreview,
|
||||
fetchGroundReview,
|
||||
fetchGroundReviewFrame,
|
||||
type GroundQualification,
|
||||
type GroundReview,
|
||||
type GroundReviewFrame,
|
||||
type GroundReviewFrameSummary,
|
||||
} from "../core/polygon/groundQualification";
|
||||
import {
|
||||
fetchPolygonRunCatalog,
|
||||
@@ -23,12 +28,13 @@ import {
|
||||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
import { PolygonLivePanel } from "./PolygonLivePanel";
|
||||
|
||||
interface PolygonRunWorkspaceProps {
|
||||
route: PolygonRunRoute;
|
||||
}
|
||||
|
||||
type ReviewOrder = "sequence" | "best" | "worst";
|
||||
|
||||
const stateLabels: Record<PolygonRunState, string> = {
|
||||
admitted: "Допущен",
|
||||
starting: "Запускается",
|
||||
@@ -40,6 +46,21 @@ const stateLabels: Record<PolygonRunState, string> = {
|
||||
aborted: "Прерван",
|
||||
};
|
||||
|
||||
const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [
|
||||
["intensity", "Облако"],
|
||||
["ground-truth", "Эталон"],
|
||||
["current", "Current"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Ошибки Current"],
|
||||
["candidate-disagreement", "Ошибки Patchwork++"],
|
||||
];
|
||||
|
||||
const orderLabels: ReadonlyArray<[ReviewOrder, string]> = [
|
||||
["sequence", "По записи"],
|
||||
["best", "Лучшие"],
|
||||
["worst", "Худшие"],
|
||||
];
|
||||
|
||||
function stateTone(
|
||||
state: PolygonRunState,
|
||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||
@@ -69,7 +90,7 @@ function formatBytes(value: number): string {
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return "Не удалось прочитать доказательства прогона.";
|
||||
return "Не удалось прочитать прогон.";
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
@@ -79,10 +100,65 @@ function formatPercent(value: number): string {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDelta(value: number): string {
|
||||
const points = value * 100;
|
||||
return `${points >= 0 ? "+" : ""}${points.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} п.п.`;
|
||||
}
|
||||
|
||||
function formatMilliseconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`;
|
||||
}
|
||||
|
||||
function frameScene(frame: GroundReviewFrame | null) {
|
||||
if (!frame) return null;
|
||||
return {
|
||||
pointCount: frame.pointCount,
|
||||
pointsXyzM: frame.pointsXyzM,
|
||||
intensity0To255: frame.intensity0To255,
|
||||
masks: {
|
||||
currentGround: frame.currentGround,
|
||||
currentAssigned: frame.evaluated,
|
||||
candidateGround: frame.patchworkGround,
|
||||
candidateAssigned: frame.evaluated,
|
||||
disagreement: frame.currentDisagreement,
|
||||
candidateDisagreement: frame.patchworkDisagreement,
|
||||
groundTruthGround: frame.groundTruthGround,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function modeExplanation(mode: LidarGroundViewMode): string {
|
||||
if (mode === "intensity") return "Нейтральная геометрия нативного LiDAR-кадра.";
|
||||
if (mode === "ground-truth") return "Публичная GOOSE-разметка: земля и всё остальное.";
|
||||
if (mode === "current") return "Что текущий алгоритм Mission Core считает землёй.";
|
||||
if (mode === "candidate") return "Что Patchwork++ считает землёй.";
|
||||
if (mode === "disagreement") return "Красным — точки, где Current расходится с эталоном.";
|
||||
return "Красным — точки, где Patchwork++ расходится с эталоном.";
|
||||
}
|
||||
|
||||
function frameColor(delta: number): string {
|
||||
if (delta < 0) {
|
||||
const opacity = Math.min(0.92, 0.42 + Math.abs(delta) * 3);
|
||||
return `rgb(255 104 104 / ${opacity})`;
|
||||
}
|
||||
const opacity = Math.min(0.9, 0.25 + delta * 2.8);
|
||||
return `rgb(210 242 188 / ${opacity})`;
|
||||
}
|
||||
|
||||
function orderFrames(
|
||||
review: GroundReview,
|
||||
order: ReviewOrder,
|
||||
): GroundReviewFrameSummary[] {
|
||||
if (order === "sequence") return review.frames;
|
||||
return [...review.frames].sort((left, right) => (
|
||||
order === "best"
|
||||
? right.groundIouDelta - left.groundIouDelta
|
||||
: left.groundIouDelta - right.groundIouDelta
|
||||
));
|
||||
}
|
||||
|
||||
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
|
||||
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
|
||||
@@ -91,11 +167,14 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const [error, setError] = useState<string | null>(route.error);
|
||||
const [reloadGeneration, setReloadGeneration] = useState(0);
|
||||
const [qualification, setQualification] = useState<GroundQualification | null>(null);
|
||||
const [qualificationError, setQualificationError] = useState<string | null>(null);
|
||||
const [failurePreview, setFailurePreview] = useState<GroundFailurePreview | null>(null);
|
||||
const [selectedFailureFrameId, setSelectedFailureFrameId] = useState<string | null>(null);
|
||||
const [failureMode, setFailureMode] =
|
||||
useState<LidarGroundViewMode>("candidate-disagreement");
|
||||
const [review, setReview] = useState<GroundReview | null>(null);
|
||||
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(null);
|
||||
const [reviewFrame, setReviewFrame] = useState<GroundReviewFrame | null>(null);
|
||||
const [reviewMode, setReviewMode] = useState<LidarGroundViewMode>("ground-truth");
|
||||
const [reviewOrder, setReviewOrder] = useState<ReviewOrder>("sequence");
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const frameCache = useRef(new Map<string, GroundReviewFrame>());
|
||||
|
||||
useEffect(() => {
|
||||
if (route.error) {
|
||||
@@ -135,79 +214,134 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const runId = detail?.run.runId;
|
||||
if (!runId) {
|
||||
setQualification(null);
|
||||
setQualificationError(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setQualification(null);
|
||||
setQualificationError(null);
|
||||
setFailurePreview(null);
|
||||
setSelectedFailureFrameId(null);
|
||||
void fetchGroundQualification(runId, { signal: controller.signal })
|
||||
.then((result) => {
|
||||
setReview(null);
|
||||
setReviewFrame(null);
|
||||
setSelectedFrameId(null);
|
||||
setReviewError(null);
|
||||
setPlaying(false);
|
||||
frameCache.current.clear();
|
||||
if (!runId) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const nextQualification = await fetchGroundQualification(runId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setQualification(result);
|
||||
setSelectedFailureFrameId(result?.worstFrames[0]?.frameId ?? null);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError));
|
||||
});
|
||||
setQualification(nextQualification);
|
||||
if (!nextQualification) return;
|
||||
const nextReview = await fetchGroundReview(runId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setReview(nextReview);
|
||||
setSelectedFrameId(nextReview?.frames[0]?.frameId ?? null);
|
||||
} catch (loadError) {
|
||||
if (!controller.signal.aborted) setReviewError(errorMessage(loadError));
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [detail?.run.runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const runId = qualification?.runId;
|
||||
if (!runId || !selectedFailureFrameId) {
|
||||
setFailurePreview(null);
|
||||
const runId = review?.runId;
|
||||
if (!runId || !selectedFrameId) {
|
||||
setReviewFrame(null);
|
||||
return;
|
||||
}
|
||||
const cached = frameCache.current.get(selectedFrameId);
|
||||
if (cached) {
|
||||
setReviewFrame(cached);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setFailurePreview(null);
|
||||
void fetchGroundFailurePreview(runId, selectedFailureFrameId, {
|
||||
setReviewFrame(null);
|
||||
void fetchGroundReviewFrame(runId, selectedFrameId, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setFailurePreview(result);
|
||||
.then((frame) => {
|
||||
if (controller.signal.aborted) return;
|
||||
frameCache.current.set(frame.frameId, frame);
|
||||
while (frameCache.current.size > 24) {
|
||||
const oldest = frameCache.current.keys().next().value as string | undefined;
|
||||
if (oldest) frameCache.current.delete(oldest);
|
||||
else break;
|
||||
}
|
||||
setReviewFrame(frame);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError));
|
||||
if (!controller.signal.aborted) setReviewError(errorMessage(loadError));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [qualification?.runId, selectedFailureFrameId]);
|
||||
}, [review?.runId, selectedFrameId]);
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() => detail ? [...detail.events].reverse() : [],
|
||||
[detail],
|
||||
const orderedFrames = useMemo(
|
||||
() => review ? orderFrames(review, reviewOrder) : [],
|
||||
[review, reviewOrder],
|
||||
);
|
||||
const failureFrame = useMemo(() => {
|
||||
if (!failurePreview) return null;
|
||||
return {
|
||||
pointCount: failurePreview.pointCount,
|
||||
pointsXyzM: failurePreview.pointsXyzM,
|
||||
intensity0To255: null,
|
||||
masks: {
|
||||
currentGround: failurePreview.currentGround,
|
||||
currentAssigned: failurePreview.evaluated,
|
||||
candidateGround: failurePreview.patchworkGround,
|
||||
candidateAssigned: failurePreview.evaluated,
|
||||
disagreement: failurePreview.currentDisagreement,
|
||||
candidateDisagreement: failurePreview.patchworkDisagreement,
|
||||
groundTruthGround: failurePreview.groundTruthGround,
|
||||
},
|
||||
};
|
||||
}, [failurePreview]);
|
||||
const selectedPosition = Math.max(
|
||||
0,
|
||||
orderedFrames.findIndex((frame) => frame.frameId === selectedFrameId),
|
||||
);
|
||||
const selectedSummary = orderedFrames[selectedPosition] ?? null;
|
||||
const bestFrame = useMemo(
|
||||
() => review
|
||||
? [...review.frames].sort(
|
||||
(left, right) => right.groundIouDelta - left.groundIouDelta,
|
||||
)[0]
|
||||
: null,
|
||||
[review],
|
||||
);
|
||||
const worstFrame = useMemo(
|
||||
() => review
|
||||
? [...review.frames].sort(
|
||||
(left, right) => left.groundIouDelta - right.groundIouDelta,
|
||||
)[0]
|
||||
: null,
|
||||
[review],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || !reviewFrame || !selectedFrameId || !orderedFrames.length) return;
|
||||
if (reviewFrame.frameId !== selectedFrameId) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const nextPosition = selectedPosition + 1;
|
||||
if (nextPosition >= orderedFrames.length) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
setSelectedFrameId(orderedFrames[nextPosition].frameId);
|
||||
}, 520);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
orderedFrames,
|
||||
playing,
|
||||
reviewFrame,
|
||||
selectedFrameId,
|
||||
selectedPosition,
|
||||
]);
|
||||
|
||||
const scene = useMemo(() => frameScene(reviewFrame), [reviewFrame]);
|
||||
|
||||
const selectPosition = (position: number) => {
|
||||
const frame = orderedFrames[Math.max(0, Math.min(position, orderedFrames.length - 1))];
|
||||
if (frame) setSelectedFrameId(frame.frameId);
|
||||
};
|
||||
|
||||
const changeOrder = (order: ReviewOrder) => {
|
||||
setPlaying(false);
|
||||
setReviewOrder(order);
|
||||
};
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="accent">Только чтение</StatusBadge>
|
||||
<h2>Проверяем журнал прогона</h2>
|
||||
<p>Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.</p>
|
||||
</GlassSurface>
|
||||
<section className="polygon-run-message">
|
||||
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
|
||||
<h2>Открываем запись</h2>
|
||||
<p>Читаем индекс кадров и готовим покадровый просмотр.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,19 +349,18 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<section className="polygon-run-message">
|
||||
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
|
||||
<h2>UI-0 не может открыть прогон</h2>
|
||||
<h2>Не удалось открыть прогон</h2>
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
||||
>
|
||||
Повторить чтение
|
||||
Повторить
|
||||
</Button>
|
||||
</GlassSurface>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -235,12 +368,11 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="neutral">Журнал пуст</StatusBadge>
|
||||
<h2>Квалификационных прогонов пока нет</h2>
|
||||
<p>Экран появится автоматически после публикации первого журнала в read-only источник.</p>
|
||||
</GlassSurface>
|
||||
<section className="polygon-run-message">
|
||||
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
|
||||
<h2>Записей пока нет</h2>
|
||||
<p>Здесь появятся replay- и simulation-прогоны после публикации результатов.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -248,82 +380,236 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const { run } = detail;
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<section className="polygon-review-lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span>
|
||||
<h2>{run.runId}</h2>
|
||||
<span className="section-eyebrow">ПОЛИГОН / ЗАПИСЬ ДАТАСЕТА</span>
|
||||
<h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
|
||||
<p>
|
||||
Канонический архив Mission Core. Lifecycle live-контура отделён от истории;
|
||||
команд физическим актуаторам и реального управления здесь нет.
|
||||
{qualification
|
||||
? `${qualification.frameCount} кадров · Current и Patchwork++ против публичной разметки`
|
||||
: "Архивный прогон без покадрового perception review."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="polygon-run-lead-status">
|
||||
<div>
|
||||
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
|
||||
<span>read-only · {run.reproducibilityTier}</span>
|
||||
{catalog && catalog.items.length > 1 ? (
|
||||
<select
|
||||
aria-label="Выбрать прогон"
|
||||
value={run.runId}
|
||||
onChange={(event) => setSelectedRunId(event.target.value)}
|
||||
>
|
||||
{catalog.items.map((item) => (
|
||||
<option key={item.runId} value={item.runId}>{item.runId}</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="polygon-run-metrics" aria-label="Сводка прогона">
|
||||
<div>
|
||||
<span>Состояние</span>
|
||||
<strong>{stateLabels[run.state]}</strong>
|
||||
<small>{run.terminalReason ?? "терминальная причина отсутствует"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Провайдеры</span>
|
||||
<strong>{run.providers.length}</strong>
|
||||
<small>{run.providerIds.join(" · ")}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>События</span>
|
||||
<strong>{detail.eventsTotal}</strong>
|
||||
<small>{detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Команды</span>
|
||||
<strong>{detail.commandCount}</strong>
|
||||
<small>содержимое не публикуется UI-0</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{qualification ? (
|
||||
<section className="polygon-qualification" aria-label="Квалификация ground provider">
|
||||
<header className="polygon-qualification__heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">GOOSE VALIDATION · {qualification.frameCount} КАДРОВ</span>
|
||||
<h3>Current против Patchwork++</h3>
|
||||
<p>
|
||||
Публичная разметка проверяет переносимость ground pipeline. Результат остаётся
|
||||
shadow-only и не даёт права на навигацию или safety.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
|
||||
{qualification.decision.passed ? "Shadow candidate" : "Gate не пройден"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="polygon-qualification__providers">
|
||||
{([
|
||||
["Current", qualification.current],
|
||||
["Patchwork++", qualification.patchwork],
|
||||
] as const).map(([label, aggregate]) => (
|
||||
<article key={label}>
|
||||
<strong>{label}</strong>
|
||||
<dl>
|
||||
<div><dt>Ground IoU</dt><dd>{formatPercent(aggregate.micro.groundIou)}</dd></div>
|
||||
<div><dt>Natural ground</dt><dd>{formatPercent(aggregate.micro.naturalGroundRecall)}</dd></div>
|
||||
<div><dt>Obstacle recall</dt><dd>{formatPercent(aggregate.micro.obstacleNonGroundRecall)}</dd></div>
|
||||
<div><dt>Latency p95</dt><dd>{formatMilliseconds(aggregate.latencyMs.p95)}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="polygon-qualification__grid">
|
||||
<article className="polygon-qualification__checks">
|
||||
<span className="section-eyebrow">ЗАРАНЕЕ ЗАФИКСИРОВАННЫЕ GATES</span>
|
||||
{qualification && review ? (
|
||||
<>
|
||||
<section className="polygon-review-player" aria-label="Покадровый просмотр GOOSE">
|
||||
<header className="polygon-review-player__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">КАДР {selectedSummary
|
||||
? selectedSummary.sequence + 1
|
||||
: "—"} / {review.frameCount}</span>
|
||||
<strong>{selectedSummary?.frameId ?? "Загружаем запись"}</strong>
|
||||
</div>
|
||||
<div className="polygon-review-mode" aria-label="Режим визуализации">
|
||||
{viewModes.map(([mode, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
data-active={reviewMode === mode ? "true" : undefined}
|
||||
onClick={() => setReviewMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="polygon-review-stage">
|
||||
{scene ? (
|
||||
<LidarGroundPointCloud frame={scene} mode={reviewMode} />
|
||||
) : (
|
||||
<div className="polygon-review-stage__loading">Загружаем кадр…</div>
|
||||
)}
|
||||
<div className="polygon-review-stage__legend">
|
||||
<strong>{viewModes.find(([mode]) => mode === reviewMode)?.[1]}</strong>
|
||||
<span>{modeExplanation(reviewMode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSummary ? (
|
||||
<div className="polygon-review-frame-metrics">
|
||||
<article>
|
||||
<span>Current · Ground IoU</span>
|
||||
<strong>{formatPercent(selectedSummary.current.groundIou)}</strong>
|
||||
<small>прогноз против эталона</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Patchwork++ · Ground IoU</span>
|
||||
<strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong>
|
||||
<small>прогноз против эталона</small>
|
||||
</article>
|
||||
<article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}>
|
||||
<span>Разница на этом кадре</span>
|
||||
<strong>{formatDelta(selectedSummary.groundIouDelta)}</strong>
|
||||
<small>Patchwork++ минус Current</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Natural ground · PW++</span>
|
||||
<strong>{formatPercent(
|
||||
selectedSummary.patchwork.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<small>recall на естественном грунте</small>
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="polygon-review-controls">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={playing ? "Пауза" : "Воспроизвести"}
|
||||
onClick={() => setPlaying((value) => !value)}
|
||||
>
|
||||
{playing ? "Пауза" : "Play"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущий кадр"
|
||||
disabled={selectedPosition <= 0}
|
||||
onClick={() => selectPosition(selectedPosition - 1)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующий кадр"
|
||||
disabled={selectedPosition >= orderedFrames.length - 1}
|
||||
onClick={() => selectPosition(selectedPosition + 1)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, orderedFrames.length - 1)}
|
||||
value={selectedPosition}
|
||||
aria-label="Позиция в записи"
|
||||
onChange={(event) => selectPosition(Number(event.target.value))}
|
||||
/>
|
||||
<span>{selectedPosition + 1} / {orderedFrames.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="polygon-review-order">
|
||||
<div>
|
||||
{orderLabels.map(([order, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={order}
|
||||
data-active={reviewOrder === order ? "true" : undefined}
|
||||
onClick={() => changeOrder(order)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bestFrame && setSelectedFrameId(bestFrame.frameId)}
|
||||
>
|
||||
Лучший прирост
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => worstFrame && setSelectedFrameId(worstFrame.frameId)}
|
||||
>
|
||||
Худшее ухудшение
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="polygon-review-timeline"
|
||||
aria-label={`Все ${orderedFrames.length} кадров`}
|
||||
>
|
||||
{orderedFrames.map((frame, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={frame.frameId}
|
||||
aria-label={`Кадр ${frame.sequence + 1}: ${formatDelta(frame.groundIouDelta)}`}
|
||||
title={`${frame.frameId} · ${formatDelta(frame.groundIouDelta)}`}
|
||||
data-active={frame.frameId === selectedFrameId ? "true" : undefined}
|
||||
style={{ "--frame-color": frameColor(frame.groundIouDelta) } as CSSProperties}
|
||||
onClick={() => selectPosition(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="polygon-review-timeline-note">
|
||||
Каждая риска — доступный кадр. Светлая означает улучшение IoU, красная —
|
||||
ухудшение. Можно нажать на любую и проверить результат вручную.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="polygon-review-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ВЕСЬ VALIDATION SPLIT</span>
|
||||
<h3>Что именно сравнивалось</h3>
|
||||
</div>
|
||||
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
|
||||
{qualification.decision.passed ? "27 / 27 проверок" : "Есть проваленные проверки"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<p>
|
||||
На каждом из {qualification.frameCount} нативных LiDAR-кадров оба алгоритма
|
||||
независимо решали одну задачу: какие точки являются землёй. Их ответы
|
||||
сравнивались с публичной point-aligned разметкой GOOSE.
|
||||
</p>
|
||||
<div>
|
||||
<article>
|
||||
<span>Ground IoU</span>
|
||||
<strong>{formatPercent(qualification.current.micro.groundIou)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatPercent(qualification.patchwork.micro.groundIou)}</strong>
|
||||
<small>{formatDelta(
|
||||
qualification.patchwork.micro.groundIou
|
||||
- qualification.current.micro.groundIou,
|
||||
)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Natural ground recall</span>
|
||||
<strong>{formatPercent(
|
||||
qualification.current.micro.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatPercent(
|
||||
qualification.patchwork.micro.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<small>{formatDelta(
|
||||
qualification.patchwork.micro.naturalGroundRecall
|
||||
- qualification.current.micro.naturalGroundRecall,
|
||||
)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Latency p95</span>
|
||||
<strong>{formatMilliseconds(qualification.current.latencyMs.p95)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatMilliseconds(qualification.patchwork.latencyMs.p95)}</strong>
|
||||
<small>на кадр</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details className="polygon-review-analysis">
|
||||
<summary>Полные метрики и деградации</summary>
|
||||
<div className="polygon-review-analysis__content">
|
||||
<section>
|
||||
<span className="section-eyebrow">ACCEPTANCE GATES</span>
|
||||
{qualification.checks.map((check) => (
|
||||
<p key={check.checkId} data-passed={check.passed ? "true" : "false"}>
|
||||
<i aria-hidden="true" />
|
||||
@@ -335,12 +621,9 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
</code>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="polygon-qualification__degradations">
|
||||
<span className="section-eyebrow">ДЕТЕРМИНИРОВАННЫЕ ДЕГРАДАЦИИ</span>
|
||||
<div>
|
||||
</section>
|
||||
<section>
|
||||
<span className="section-eyebrow">ДЕГРАДАЦИИ</span>
|
||||
{Object.entries(qualification.degradations).map(([profileId, aggregate]) => (
|
||||
<p key={profileId}>
|
||||
<strong>{profileId}</strong>
|
||||
@@ -349,194 +632,59 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
<span>p95 {formatMilliseconds(aggregate.latencyMs.p95)}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="polygon-qualification__failures">
|
||||
<article>
|
||||
<span className="section-eyebrow">ХУДШИЕ КАДРЫ</span>
|
||||
<div>
|
||||
{qualification.worstFrames.slice(0, 5).map((frame) => (
|
||||
<button
|
||||
type="button"
|
||||
key={frame.frameId}
|
||||
data-active={frame.frameId === selectedFailureFrameId ? "true" : undefined}
|
||||
onClick={() => setSelectedFailureFrameId(frame.frameId)}
|
||||
>
|
||||
<span>{frame.frameId}</span>
|
||||
<code>
|
||||
PW {formatPercent(frame.patchworkGroundIou)} ·
|
||||
Δ {formatPercent(frame.groundIouDelta)}
|
||||
</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<article className="polygon-qualification__viewer">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РАЗБОР ОШИБКИ</span>
|
||||
<strong>{failurePreview?.frameId ?? "Загружаем кадр"}</strong>
|
||||
</div>
|
||||
<div className="lidar-ground-modes" aria-label="Режим отображения ошибки">
|
||||
{([
|
||||
["ground-truth", "Ground truth"],
|
||||
["current", "Current"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Ошибка Current"],
|
||||
["candidate-disagreement", "Ошибка PW++"],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
data-active={failureMode === mode ? "true" : undefined}
|
||||
onClick={() => setFailureMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
{failureFrame ? (
|
||||
<LidarGroundPointCloud frame={failureFrame} mode={failureMode} />
|
||||
) : (
|
||||
<div className="polygon-qualification__viewer-empty">
|
||||
Читаем bounded preview из sealed-артефакта прогона…
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
) : qualificationError ? (
|
||||
<div className="polygon-qualification__error">
|
||||
Квалификационный отчёт недоступен: {qualificationError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="polygon-run-layout">
|
||||
<GlassSurface className="polygon-run-catalog" padding="lg">
|
||||
<header className="polygon-run-panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ПРОГОНЫ</span>
|
||||
<h3>{catalog?.total ?? 0} в источнике</h3>
|
||||
</section>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</header>
|
||||
<div className="polygon-run-list">
|
||||
{catalog?.items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.runId}
|
||||
data-active={item.runId === run.runId ? "true" : undefined}
|
||||
onClick={() => setSelectedRunId(item.runId)}
|
||||
>
|
||||
<i data-state={item.state} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{item.runId}</strong>
|
||||
<small>{formatTimestamp(item.createdAtUtc)}</small>
|
||||
</span>
|
||||
<em>{stateLabels[item.state]}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</details>
|
||||
</>
|
||||
) : qualification ? (
|
||||
<section className="polygon-review-unavailable">
|
||||
<span className="section-eyebrow">ПОКАДРОВЫЙ ПРОСМОТР</span>
|
||||
<h3>Review-pack ещё не опубликован</h3>
|
||||
<p>
|
||||
Числовой отчёт есть, но мы не показываем его как достаточный результат:
|
||||
оператор должен иметь возможность проверить все {qualification.frameCount} кадров.
|
||||
</p>
|
||||
{reviewError ? <small>{reviewError}</small> : null}
|
||||
</section>
|
||||
) : (
|
||||
<section className="polygon-review-unavailable">
|
||||
<span className="section-eyebrow">АРХИВНЫЙ ПРОГОН</span>
|
||||
<h3>В этой записи нет perception review</h3>
|
||||
<p>Технические доказательства доступны в раскрытии ниже.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<GlassSurface className="polygon-run-identity" padding="lg">
|
||||
<span className="section-eyebrow">ИДЕНТИЧНОСТЬ И ГРАНИЦА</span>
|
||||
<details className="polygon-run-technical">
|
||||
<summary>Технические детали прогона</summary>
|
||||
<div className="polygon-run-technical__content">
|
||||
<dl>
|
||||
<div><dt>Run ID</dt><dd>{run.runId}</dd></div>
|
||||
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
|
||||
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
|
||||
<div><dt>Host profile</dt><dd>{run.hostProfileId}</dd></div>
|
||||
<div><dt>Mission Core</dt><dd><code>{run.missionCoreCommit.slice(0, 12)}</code></dd></div>
|
||||
<div><dt>Clock</dt><dd><code>{run.clockDomain}</code></dd></div>
|
||||
<div><dt>Seed</dt><dd>{run.seed}</dd></div>
|
||||
<div><dt>Mission Core</dt><dd>{run.missionCoreCommit.slice(0, 12)}</dd></div>
|
||||
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
|
||||
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
|
||||
</dl>
|
||||
<div className="polygon-run-safety-boundary">
|
||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||
<p>
|
||||
Actuator authority: {run.authority.actuatorAuthority ? "да" : "нет"} ·
|
||||
direct setpoints: {run.authority.directActuatorSetpointsAllowed ? "да" : "нет"} ·
|
||||
navigation/safety accepted: {run.authority.navigationOrSafetyAccepted ? "да" : "нет"}
|
||||
</p>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
|
||||
<section className="polygon-run-providers" aria-label="Провайдеры прогона">
|
||||
{run.providers.map((provider) => (
|
||||
<div key={provider.identifier}>
|
||||
<i aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{provider.identifier}</strong>
|
||||
<small>{provider.version}</small>
|
||||
</span>
|
||||
<code>{provider.revision.slice(0, 16)}</code>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<GlassSurface className="polygon-run-events" padding="lg">
|
||||
<header className="polygon-run-panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЖУРНАЛ СОБЫТИЙ</span>
|
||||
<h3>Последние переходы и факты</h3>
|
||||
</div>
|
||||
<span>revision {run.revision}</span>
|
||||
</header>
|
||||
<div className="polygon-run-event-list">
|
||||
{visibleEvents.map((event) => (
|
||||
<article key={event.sequence}>
|
||||
<span className="polygon-run-event-sequence">
|
||||
{String(event.sequence).padStart(3, "0")}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{event.eventType}</strong>
|
||||
<small>{formatTimestamp(event.observedAtUtc)}</small>
|
||||
<code>{JSON.stringify(event.payload)}</code>
|
||||
</div>
|
||||
<span>{event.simTimeNs === null ? "host" : `${event.simTimeNs} ns`}</span>
|
||||
</article>
|
||||
))}
|
||||
<section>
|
||||
<h4>Провайдеры</h4>
|
||||
{run.providers.map((provider) => (
|
||||
<p key={provider.identifier}>
|
||||
<strong>{provider.identifier}</strong>
|
||||
<span>{provider.version} · {provider.revision.slice(0, 16)}</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<section>
|
||||
<h4>Артефакты</h4>
|
||||
{detail.artifacts.map((artifact) => (
|
||||
<p key={artifact.artifactId}>
|
||||
<strong>{artifact.kind}</strong>
|
||||
<span>{formatBytes(artifact.byteLength)} · {artifact.sha256.slice(0, 12)}</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="polygon-run-evidence-grid">
|
||||
<GlassSurface className="polygon-run-artifacts" padding="lg">
|
||||
<span className="section-eyebrow">АРТЕФАКТЫ</span>
|
||||
<h3>{detail.artifacts.length || run.artifactCount} ссылок в индексе</h3>
|
||||
{detail.artifacts.length ? (
|
||||
<div>
|
||||
{detail.artifacts.map((artifact) => (
|
||||
<article key={artifact.artifactId}>
|
||||
<span>
|
||||
<strong>{artifact.kind}</strong>
|
||||
<small>{artifact.relativePath}</small>
|
||||
</span>
|
||||
<code>{artifact.sha256.slice(0, 12)} · {formatBytes(artifact.byteLength)}</code>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>В журнале прогона нет зарегистрированных artifact-index записей.</p>
|
||||
)}
|
||||
</GlassSurface>
|
||||
<GlassSurface className="polygon-run-limitations" padding="lg">
|
||||
<span className="section-eyebrow">ЧЕСТНАЯ ГРАНИЦА UI-0</span>
|
||||
<h3>Что этот результат ещё не доказывает</h3>
|
||||
<ul>
|
||||
{detail.limitations.map((limitation) => <li key={limitation}>{limitation}</li>)}
|
||||
</ul>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user