fix(polygon): separate dataset sources from runs

This commit is contained in:
DCCONSTRUCTIONS 2026-07-25 18:40:56 +03:00
parent 2e5c4ba1c5
commit e97573e221
13 changed files with 1036 additions and 658 deletions

View File

@ -73,6 +73,9 @@ export interface GroundReviewFrameMetrics {
export interface GroundReviewFrameSummary {
sequence: number;
frameId: string;
datasetSequenceId: string;
datasetFrameNumber: number;
sensorTimestampNs: string;
sourcePointCount: number;
pointCount: number;
current: GroundReviewFrameMetrics;
@ -108,6 +111,8 @@ type QualificationFetch = (
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const GOOSE_FRAME_ID =
/^([A-Za-z0-9][A-Za-z0-9_-]{0,95})__([0-9]{4})_([0-9]{16,20})$/;
function record(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
@ -373,7 +378,7 @@ function reviewFrameMetrics(value: unknown, field: string): GroundReviewFrameMet
export function decodeGroundReview(payload: unknown): GroundReview {
const source = record(payload, "ground review");
if (
source.schema_version !== "missioncore.polygon-ground-review/v1"
source.schema_version !== "missioncore.polygon-ground-review/v2"
|| source.access !== "read-only"
) {
throw new GroundQualificationContractError("Неизвестная схема покадрового просмотра.");
@ -386,9 +391,36 @@ export function decodeGroundReview(payload: unknown): GroundReview {
if (sequence !== index) {
throw new GroundQualificationContractError("Кадры просмотра идут не по порядку.");
}
const frameId = id(frame.frame_id, `frames[${index}].frame_id`);
const frameIdentity = parseGooseFrameId(frameId);
const datasetSequenceId = id(
frame.dataset_sequence_id,
`frames[${index}].dataset_sequence_id`,
);
const datasetFrameNumber = integer(
frame.dataset_frame_number,
`frames[${index}].dataset_frame_number`,
);
const sensorTimestampNs = text(
frame.sensor_timestamp_ns,
`frames[${index}].sensor_timestamp_ns`,
20,
);
if (
datasetSequenceId !== frameIdentity.datasetSequenceId
|| datasetFrameNumber !== frameIdentity.datasetFrameNumber
|| sensorTimestampNs !== frameIdentity.sensorTimestampNs
) {
throw new GroundQualificationContractError(
`frames[${index}] противоречит идентичности GOOSE-кадра.`,
);
}
return {
sequence,
frameId: id(frame.frame_id, `frames[${index}].frame_id`),
frameId,
datasetSequenceId,
datasetFrameNumber,
sensorTimestampNs,
sourcePointCount: integer(
frame.source_point_count,
`frames[${index}].source_point_count`,
@ -432,6 +464,26 @@ export function decodeGroundReview(payload: unknown): GroundReview {
};
}
export interface GooseFrameIdentity {
datasetSequenceId: string;
datasetFrameNumber: number;
sensorTimestampNs: string;
}
export function parseGooseFrameId(frameId: string): GooseFrameIdentity {
const match = GOOSE_FRAME_ID.exec(frameId);
if (!match) {
throw new GroundQualificationContractError(
"frame_id не содержит GOOSE sequence/frame/timestamp.",
);
}
return {
datasetSequenceId: match[1],
datasetFrameNumber: Number(match[2]),
sensorTimestampNs: match[3],
};
}
export function decodeGroundReviewFrame(payload: unknown): GroundReviewFrame {
const source = record(payload, "ground review frame");
if (

View File

@ -156,7 +156,7 @@ export const workspaces: WorkspaceDefinition[] = [
label: "Прогоны",
title: "Прогоны",
eyebrow: "ПОЛИГОН / ПРОГОНЫ",
description: "Покадровый replay, сравнение алгоритмов и история квалификационных прогонов.",
description: "История simulation, dataset replay и shadow-запусков с выбором конкретного прогона.",
icon: "activity",
kind: "polygon-run",
groups: [],

View File

@ -97,6 +97,10 @@
}
@media (max-width: 1040px) {
.dataset-sequence-review__truth {
grid-template-columns: 1fr;
}
.polygon-review-summary > div:last-child,
.polygon-review-analysis__content,
.polygon-run-technical__content {
@ -213,6 +217,20 @@
grid-template-columns: 1fr;
}
.polygon-run-picker,
.dataset-result-select,
.dataset-sequence-select,
.polygon-run-input {
align-items: stretch;
flex-direction: column;
}
.polygon-run-picker select,
.dataset-result-select select,
.dataset-sequence-select select {
width: 100%;
}
.polygon-review-controls {
grid-template-columns: auto auto auto minmax(0, 1fr);
}

View File

@ -1023,6 +1023,84 @@
padding: 0.15rem 0.1rem 0.25rem;
}
.polygon-run-picker,
.dataset-result-select,
.dataset-sequence-select {
display: flex;
min-width: 0;
align-items: center;
gap: 0.75rem;
border-radius: 0.9rem;
background: var(--station-panel);
padding: 0.68rem 0.8rem;
}
.polygon-run-picker > div,
.dataset-result-select > div {
display: grid;
flex: 0 0 auto;
gap: 0.18rem;
}
.polygon-run-picker strong,
.dataset-result-select strong,
.dataset-sequence-select label {
color: var(--nodedc-text-primary);
font-size: 0.65rem;
font-weight: 650;
}
.polygon-run-picker select,
.dataset-result-select select,
.dataset-sequence-select select {
min-width: 0;
flex: 1 1 auto;
border: 0;
border-radius: 999px;
outline: 0;
background: rgb(255 255 255 / 0.07);
color: var(--nodedc-text-secondary);
padding: 0.48rem 0.75rem;
font: inherit;
font-size: 0.6rem;
}
.polygon-run-picker small,
.dataset-sequence-select > span {
flex: 0 0 auto;
color: var(--nodedc-text-muted);
font-size: 0.55rem;
}
.polygon-run-input {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 1rem;
background: var(--station-panel);
padding: 0.9rem 1rem;
}
.polygon-run-input h3,
.polygon-run-input p {
margin: 0;
}
.polygon-run-input h3 {
margin-top: 0.28rem;
color: var(--nodedc-text-primary);
font-size: 0.94rem;
}
.polygon-run-input p {
max-width: 48rem;
margin-top: 0.32rem;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.5;
}
.polygon-review-lead h2,
.polygon-review-lead p {
margin: 0;
@ -1064,6 +1142,91 @@
background: var(--station-panel);
}
.dataset-sequence-review {
display: grid;
gap: 0.7rem;
}
.dataset-sequence-review__truth {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(20rem, 0.6fr);
gap: 1rem;
border-radius: 1rem;
background: var(--station-panel);
padding: 0.95rem 1rem;
}
.dataset-sequence-review__truth h2,
.dataset-sequence-review__truth p,
.dataset-sequence-review__truth dl {
margin: 0;
}
.dataset-sequence-review__truth h2 {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
font-size: 1.05rem;
letter-spacing: -0.025em;
}
.dataset-sequence-review__truth p {
max-width: 52rem;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
font-size: 0.63rem;
line-height: 1.55;
}
.dataset-sequence-review__truth dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: 0.3rem;
}
.dataset-sequence-review__truth dl > div {
display: grid;
align-content: center;
gap: 0.24rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.035);
padding: 0.65rem;
}
.dataset-sequence-review__truth dt {
color: var(--nodedc-text-muted);
font-size: 0.52rem;
}
.dataset-sequence-review__truth dd {
margin: 0;
color: var(--nodedc-text-primary);
font-size: 0.82rem;
font-weight: 670;
}
.dataset-sequence-select {
border-radius: 0.9rem;
}
.dataset-sequence-select label {
flex: 0 0 auto;
}
.dataset-sequence-review__raw-note {
margin: 0;
border-radius: 0.9rem;
background: rgb(var(--nodedc-warning-rgb) / 0.08);
color: var(--nodedc-text-muted);
padding: 0.8rem 0.9rem;
font-size: 0.6rem;
line-height: 1.5;
}
.dataset-sequence-timeline {
height: 2rem;
}
.polygon-review-player__header {
display: flex;
min-width: 0;

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import {
Button,
GlassSurface,
@ -7,16 +7,13 @@ import {
import {
fetchDatasetGatewayCatalog,
fetchDatasetGroundComparison,
fetchDatasetNativeScanPreview,
type DatasetGatewayCatalog,
type DatasetGroundComparison,
type DatasetNativeScanPreview,
} from "../core/lidar/datasetGateway";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
fetchPolygonRunCatalog,
type PolygonRunCatalog,
} from "../core/polygon/runArchive";
import { GooseDatasetReview } from "./GooseDatasetReview";
const representationLabels: Record<string, string> = {
"native-scan": "Исходный скан",
@ -51,18 +48,22 @@ function formatBytes(value: number): string {
}).format(value / 1_000_000_000);
}
function isGooseQualificationRun(
item: PolygonRunCatalog["items"][number],
): boolean {
return item.kind === "replay_shadow"
&& item.scenarioGeneration.startsWith("goose-3d");
}
export function DatasetGatewayWorkspace() {
const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reloadGeneration, setReloadGeneration] = useState(0);
const [previewOpen, setPreviewOpen] = useState(false);
const [preview, setPreview] = useState<DatasetNativeScanPreview | null>(null);
const [comparison, setComparison] = useState<DatasetGroundComparison | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [comparisonError, setComparisonError] = useState<string | null>(null);
const [previewMode, setPreviewMode] =
useState<LidarGroundViewMode>("semantic");
const [runCatalog, setRunCatalog] = useState<PolygonRunCatalog | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runError, setRunError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
@ -93,64 +94,29 @@ export function DatasetGatewayWorkspace() {
}, [catalog]);
useEffect(() => {
if (!previewOpen || preview) return;
const controller = new AbortController();
setPreviewError(null);
void fetchDatasetNativeScanPreview({ signal: controller.signal })
setRunError(null);
void fetchPolygonRunCatalog({ signal: controller.signal })
.then((value) => {
if (!controller.signal.aborted) setPreview(value);
if (controller.signal.aborted) return;
setRunCatalog(value);
const requested = new URLSearchParams(window.location.search).get("run");
const gooseRuns = value.items.filter(isGooseQualificationRun);
const selected = gooseRuns.find((item) => item.runId === requested)
?? gooseRuns[0]
?? null;
setSelectedRunId(selected?.runId ?? null);
})
.catch((loadError: unknown) => {
if (!controller.signal.aborted) setPreviewError(errorMessage(loadError));
if (!controller.signal.aborted) setRunError(errorMessage(loadError));
});
return () => controller.abort();
}, [previewOpen, preview]);
useEffect(() => {
if (!previewOpen || comparison) return;
const controller = new AbortController();
setComparisonError(null);
void fetchDatasetGroundComparison({ signal: controller.signal })
.then((value) => {
if (!controller.signal.aborted) setComparison(value);
})
.catch((loadError: unknown) => {
if (!controller.signal.aborted) {
setComparisonError(errorMessage(loadError));
}
});
return () => controller.abort();
}, [comparison, previewOpen]);
const previewFrame = useMemo(() => {
if (!preview) return null;
const emptyMask = new Array<number>(preview.pointCount).fill(0);
const comparisonAligned = (
comparison
&& comparison.frameId === preview.frameId
&& comparison.pointCount === preview.pointCount
) ? comparison : null;
return {
pointCount: preview.pointCount,
pointsXyzM: preview.pointsXyzM,
intensity0To255: preview.remission0To255,
semanticRgb0To255: preview.semanticRgb0To255,
masks: {
currentGround: comparisonAligned?.currentGround ?? emptyMask,
currentAssigned: comparisonAligned?.evaluated ?? emptyMask,
candidateGround: comparisonAligned?.patchworkGround ?? emptyMask,
candidateAssigned: comparisonAligned?.patchworkAssigned ?? emptyMask,
disagreement: comparisonAligned?.currentDisagreement ?? emptyMask,
candidateDisagreement:
comparisonAligned?.patchworkDisagreement ?? emptyMask,
groundTruthGround: preview.groundTruthGround,
},
};
}, [comparison, preview]);
}, []);
const downloadProgress = catalog?.source.archive
? catalog.source.archive.bytesTransferred / catalog.source.archive.totalBytes
: null;
const gooseRuns = runCatalog?.items.filter(isGooseQualificationRun) ?? [];
return (
<div className="standard-workspace dataset-workspace">
@ -213,9 +179,9 @@ export function DatasetGatewayWorkspace() {
<span>PUBLIC · OFF-ROAD · LABELED</span>
<h3>{catalog.source.displayName}</h3>
<p>
Один оборот VLS-128 с point-wise semantic и instance labels.
Первый baseline для ground segmentation и последующих
LiDAR-native моделей.
Validation split: восемь отдельных поездок и 961 разреженный
размеченный оборот VLS-128. Это коллекция native scans для
perception, а не непрерывное видео или траектория машины.
</p>
</div>
</div>
@ -266,12 +232,12 @@ export function DatasetGatewayWorkspace() {
disabled={catalog.source.admissionStatus !== "frame-ready"}
title={
catalog.source.admissionStatus === "frame-ready"
? "Открыть первый размеченный native scan"
: "Первый реальный кадр ещё не импортирован"
? "Открыть все доступные последовательности и scans"
: "Размеченные scans ещё не импортированы"
}
onClick={() => setPreviewOpen((value) => !value)}
>
{previewOpen ? "Закрыть" : "Открыть"}
{previewOpen ? "Закрыть источник" : "Открыть источник"}
</Button>
<Button
size="compact"
@ -286,145 +252,34 @@ export function DatasetGatewayWorkspace() {
</article>
</GlassSurface>
{previewOpen ? (
<section className="dataset-preview" aria-label="Первый GOOSE native scan">
<header>
{previewOpen && selectedRunId ? (
<>
{gooseRuns.length > 0 ? (
<div className="dataset-result-select">
<div>
<span className="section-eyebrow">NATIVE SCAN · GROUND TRUTH</span>
<h2>
{preview
? `${preview.frameId} · ${preview.sourcePointCount.toLocaleString("ru-RU")} точек`
: "Первый размеченный кадр"}
</h2>
<span className="section-eyebrow">НАЛОЖЕНИЕ РЕЗУЛЬТАТА</span>
<strong>Квалификационный прогон</strong>
</div>
<div className="lidar-ground-modes" aria-label="Режим окраски">
{([
["semantic", "Классы"],
["ground-truth", "Ground"],
...(comparison
? ([
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Ошибки Current"],
["candidate-disagreement", "Ошибки PW++"],
] as const)
: []),
["intensity", "Remission"],
] as const).map(([mode, label]) => (
<button
key={mode}
type="button"
aria-pressed={previewMode === mode}
onClick={() => setPreviewMode(mode)}
<select
aria-label="Выбрать прогон для наложения"
value={selectedRunId}
onChange={(event) => setSelectedRunId(event.target.value)}
>
{label}
</button>
{gooseRuns.map((item) => (
<option key={item.runId} value={item.runId}>
{item.scenarioGeneration} · {item.runId}
</option>
))}
</select>
</div>
</header>
{comparison ? (
<dl className="dataset-preview__metrics">
<div>
<dt>Current · IoU</dt>
<dd>{(comparison.metrics.current.groundIou * 100).toFixed(1)}%</dd>
</div>
<div>
<dt>Current · Recall</dt>
<dd>{(comparison.metrics.current.recall * 100).toFixed(1)}%</dd>
</div>
<div>
<dt>Current · Natural</dt>
<dd>
{(comparison.metrics.current.naturalGroundRecall * 100).toFixed(1)}%
</dd>
</div>
<div>
<dt>Current · Latency</dt>
<dd>{comparison.latencyMs.current.toFixed(0)} ms</dd>
</div>
<div>
<dt>Patchwork++ · IoU</dt>
<dd>
{(comparison.metrics.patchworkpp.groundIou * 100).toFixed(1)}%
</dd>
</div>
<div>
<dt>Patchwork++ · Recall</dt>
<dd>{(comparison.metrics.patchworkpp.recall * 100).toFixed(1)}%</dd>
</div>
<div>
<dt>Patchwork++ · Natural</dt>
<dd>
{(comparison.metrics.patchworkpp.naturalGroundRecall * 100).toFixed(1)}%
</dd>
</div>
<div>
<dt>Patchwork++ · Latency</dt>
<dd>{comparison.latencyMs.patchworkpp.toFixed(1)} ms</dd>
</div>
</dl>
) : comparisonError ? (
<p className="dataset-preview__comparison-note">
Current baseline пока не рассчитан: {comparisonError}
</p>
) : null}
{previewFrame ? (
<>
<LidarGroundPointCloud frame={previewFrame} mode={previewMode} />
<div className="dataset-preview__legend">
{previewMode === "semantic" ? (
preview?.classes.map((item) => (
<span key={item.labelId}>
<i style={{ background: item.hex }} />
{item.className}
</span>
))
) : previewMode === "ground-truth" ? (
<>
<span><i className="dataset-legend-ground" />ground truth</span>
<span><i className="dataset-legend-other" />остальные точки</span>
<GooseDatasetReview runId={selectedRunId} />
</>
) : previewMode === "current" ? (
<>
<span><i className="dataset-legend-ground" />current ground</span>
<span><i className="dataset-legend-other" />current non-ground</span>
</>
) : previewMode === "candidate" ? (
<>
<span><i className="dataset-legend-ground" />Patchwork++ ground</span>
<span><i className="dataset-legend-other" />Patchwork++ non-ground</span>
</>
) : previewMode === "disagreement" ? (
<>
<span><i className="dataset-legend-error" />ошибка Current</span>
<span><i className="dataset-legend-other" />совпадение с labels</span>
</>
) : previewMode === "candidate-disagreement" ? (
<>
<span><i className="dataset-legend-error" />ошибка Patchwork++</span>
<span><i className="dataset-legend-other" />совпадение</span>
</>
) : (
<span>Remission нормализован только для визуального просмотра 0255</span>
)}
</div>
</>
) : (
<div className="lidar-ground-scene-placeholder">
<StatusBadge tone={previewError ? "danger" : "accent"}>
{previewError ? "Preview недоступен" : "Читаем frame"}
</StatusBadge>
<p>{previewError ?? "Проверяем point alignment и разметку."}</p>
</div>
)}
{comparison ? (
<p className="dataset-preview__comparison-note">
Patchwork++ использует опубликованную высоту VLS-128{" "}
{comparison.inputProfile.sensorHeightM.toFixed(2)} м; это
algorithm-specific admission, а не полный vehicle TF и не
normalized scan.
</p>
) : null}
) : previewOpen ? (
<section className="polygon-review-unavailable">
<StatusBadge tone="warning">Review недоступен</StatusBadge>
<h3>Источник принят, но покадровый preview не опубликован</h3>
<p>{runError ?? "Нет квалификационного прогона с bounded frame-pack."}</p>
</section>
) : null}

View File

@ -0,0 +1,460 @@
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { StatusBadge } from "@nodedc/ui-react";
import {
fetchGroundReview,
fetchGroundReviewFrame,
type GroundReview,
type GroundReviewFrame,
type GroundReviewFrameSummary,
} from "../core/polygon/groundQualification";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
interface GooseDatasetReviewProps {
runId: string;
}
export interface GooseDatasetSequence {
datasetSequenceId: string;
frames: GroundReviewFrameSummary[];
durationSeconds: number;
minimumGapSeconds: number;
maximumGapSeconds: number;
}
const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [
["intensity", "Исходный скан"],
["ground-truth", "Ground truth"],
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Ошибки Current"],
["candidate-disagreement", "Ошибки Patchwork++"],
];
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return "Не удалось открыть последовательности GOOSE.";
}
function secondsBetween(
left: GroundReviewFrameSummary,
right: GroundReviewFrameSummary,
): number {
const delta = BigInt(right.sensorTimestampNs) - BigInt(left.sensorTimestampNs);
return Number(delta) / 1_000_000_000;
}
export function groupGooseReviewFrames(
review: GroundReview,
): GooseDatasetSequence[] {
const groups = new Map<string, GroundReviewFrameSummary[]>();
review.frames.forEach((frame) => {
const frames = groups.get(frame.datasetSequenceId) ?? [];
frames.push(frame);
groups.set(frame.datasetSequenceId, frames);
});
return [...groups.entries()].map(([datasetSequenceId, frames]) => {
const gaps = frames.slice(1).map((frame, index) => (
secondsBetween(frames[index], frame)
));
return {
datasetSequenceId,
frames,
durationSeconds: frames.length > 1
? secondsBetween(frames[0], frames[frames.length - 1])
: 0,
minimumGapSeconds: gaps.length ? Math.min(...gaps) : 0,
maximumGapSeconds: gaps.length ? Math.max(...gaps) : 0,
};
});
}
function sequenceLabel(sequenceId: string): string {
const date = sequenceId.slice(0, 10);
const title = sequenceId.slice(11).replaceAll("_", " ");
return `${date} · ${title}`;
}
function formatDuration(seconds: number): string {
const rounded = Math.max(0, Math.round(seconds));
const minutes = Math.floor(rounded / 60);
const remainder = rounded % 60;
return `${minutes}:${String(remainder).padStart(2, "0")}`;
}
function formatGap(seconds: number): string {
return `${seconds.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} с`;
}
function formatPercent(value: number): string {
return new Intl.NumberFormat("ru-RU", {
style: "percent",
maximumFractionDigits: 1,
}).format(value);
}
function formatDelta(value: number): string {
const points = value * 100;
return `${points >= 0 ? "+" : ""}${points.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 "Один sensor-frame оборот VLS-128; цвет — remission.";
if (mode === "ground-truth") return "Публичная point-aligned разметка GOOSE.";
if (mode === "current") return "Результат Current для этого исходного скана.";
if (mode === "candidate") return "Результат Patchwork++ для этого исходного скана.";
if (mode === "disagreement") return "Красным отмечены ошибки Current относительно labels.";
return "Красным отмечены ошибки Patchwork++ относительно labels.";
}
export function GooseDatasetReview({ runId }: GooseDatasetReviewProps) {
const [review, setReview] = useState<GroundReview | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedSequenceId, setSelectedSequenceId] = useState<string | null>(null);
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(null);
const [reviewFrame, setReviewFrame] = useState<GroundReviewFrame | null>(null);
const [mode, setMode] = useState<LidarGroundViewMode>("intensity");
const [playing, setPlaying] = useState(false);
const [frameLoading, setFrameLoading] = useState(false);
const frameCache = useRef(new Map<string, GroundReviewFrame>());
useEffect(() => {
const controller = new AbortController();
setError(null);
setReview(null);
setReviewFrame(null);
setPlaying(false);
frameCache.current.clear();
void fetchGroundReview(runId, { signal: controller.signal })
.then((value) => {
if (controller.signal.aborted || !value) return;
setReview(value);
const first = value.frames[0] ?? null;
setSelectedSequenceId(first?.datasetSequenceId ?? null);
setSelectedFrameId(first?.frameId ?? null);
})
.catch((loadError: unknown) => {
if (!controller.signal.aborted) setError(errorMessage(loadError));
});
return () => controller.abort();
}, [runId]);
const sequences = useMemo(
() => review ? groupGooseReviewFrames(review) : [],
[review],
);
const selectedSequence = sequences.find(
(sequence) => sequence.datasetSequenceId === selectedSequenceId,
) ?? sequences[0] ?? null;
const selectedPosition = Math.max(
0,
selectedSequence?.frames.findIndex((frame) => frame.frameId === selectedFrameId) ?? 0,
);
const selectedSummary = selectedSequence?.frames[selectedPosition] ?? null;
const previousSummary = selectedSequence && selectedPosition > 0
? selectedSequence.frames[selectedPosition - 1]
: null;
const gapFromPrevious = selectedSummary && previousSummary
? secondsBetween(previousSummary, selectedSummary)
: null;
useEffect(() => {
if (!selectedFrameId) return;
const cached = frameCache.current.get(selectedFrameId);
if (cached) {
setReviewFrame(cached);
setFrameLoading(false);
return;
}
const controller = new AbortController();
setFrameLoading(true);
void fetchGroundReviewFrame(runId, selectedFrameId, {
signal: controller.signal,
})
.then((frame) => {
if (controller.signal.aborted) return;
frameCache.current.set(frame.frameId, frame);
while (frameCache.current.size > 32) {
const oldest = frameCache.current.keys().next().value as string | undefined;
if (oldest) frameCache.current.delete(oldest);
else break;
}
setReviewFrame(frame);
setFrameLoading(false);
})
.catch((loadError: unknown) => {
if (controller.signal.aborted) return;
setFrameLoading(false);
setError(errorMessage(loadError));
});
return () => controller.abort();
}, [runId, selectedFrameId]);
useEffect(() => {
if (
!playing
|| !selectedSequence
|| !selectedFrameId
|| reviewFrame?.frameId !== selectedFrameId
) return;
const timer = window.setTimeout(() => {
const next = selectedSequence.frames[selectedPosition + 1];
if (!next) {
setPlaying(false);
return;
}
setSelectedFrameId(next.frameId);
}, 700);
return () => window.clearTimeout(timer);
}, [
playing,
reviewFrame?.frameId,
selectedFrameId,
selectedPosition,
selectedSequence,
]);
const scene = useMemo(() => frameScene(reviewFrame), [reviewFrame]);
const selectPosition = (position: number) => {
if (!selectedSequence) return;
const bounded = Math.max(0, Math.min(position, selectedSequence.frames.length - 1));
setPlaying(false);
setSelectedFrameId(selectedSequence.frames[bounded]?.frameId ?? null);
};
const selectSequence = (sequenceId: string) => {
const sequence = sequences.find((candidate) => (
candidate.datasetSequenceId === sequenceId
));
setPlaying(false);
setSelectedSequenceId(sequenceId);
setSelectedFrameId(sequence?.frames[0]?.frameId ?? null);
setReviewFrame(null);
};
if (error) {
return (
<section className="polygon-review-unavailable">
<StatusBadge tone="danger">Источник недоступен</StatusBadge>
<h3>Не удалось открыть GOOSE</h3>
<p>{error}</p>
</section>
);
}
if (!review || !selectedSequence) {
return (
<section className="polygon-review-unavailable">
<StatusBadge tone="accent">Читаем индекс</StatusBadge>
<h3>Открываем последовательности GOOSE</h3>
<p>Загружаем только bounded preview, исходный архив остаётся на worker.</p>
</section>
);
}
return (
<section className="dataset-sequence-review" aria-label="Просмотр GOOSE validation">
<header className="dataset-sequence-review__truth">
<div>
<span className="section-eyebrow">СТРУКТУРА ИСХОДНИКА</span>
<h2>8 отдельных поездок, а не одно видео</h2>
<p>
Validation ZIP содержит {review.frameCount} разреженных размеченных
LiDAR-оборотов. Между ними нет непрерывной траектории машины. Play ниже
ускоренный просмотр выбранной поездки, а не воспроизведение реального времени.
</p>
</div>
<dl>
<div><dt>Последовательностей</dt><dd>{sequences.length}</dd></div>
<div><dt>Размеченных сканов</dt><dd>{review.frameCount}</dd></div>
<div><dt>Preview</dt><dd>{review.previewPoints.toLocaleString("ru-RU")} точек</dd></div>
</dl>
</header>
<div className="dataset-sequence-select">
<label htmlFor="goose-sequence">Последовательность</label>
<select
id="goose-sequence"
value={selectedSequence.datasetSequenceId}
onChange={(event) => selectSequence(event.target.value)}
>
{sequences.map((sequence) => (
<option
key={sequence.datasetSequenceId}
value={sequence.datasetSequenceId}
>
{sequenceLabel(sequence.datasetSequenceId)}
{" · "}{sequence.frames.length} scans
</option>
))}
</select>
<span>
{selectedSequence.frames.length} размеченных сканов · интервал{" "}
{formatGap(selectedSequence.minimumGapSeconds)}{
formatGap(selectedSequence.maximumGapSeconds)
} · охват {formatDuration(selectedSequence.durationSeconds)}
</span>
</div>
<section className="polygon-review-player">
<header className="polygon-review-player__header">
<div>
<span className="section-eyebrow">
SCAN {selectedPosition + 1} / {selectedSequence.frames.length}
{" · SOURCE FRAME "}{selectedSummary?.datasetFrameNumber ?? "—"}
</span>
<strong>{selectedSummary?.frameId ?? "Загружаем скан"}</strong>
</div>
<div className="polygon-review-mode" aria-label="Режим визуализации">
{viewModes.map(([viewMode, label]) => (
<button
type="button"
key={viewMode}
data-active={mode === viewMode ? "true" : undefined}
onClick={() => setMode(viewMode)}
>
{label}
</button>
))}
</div>
</header>
<div className="polygon-review-controls">
<button
type="button"
aria-label={playing ? "Пауза" : "Воспроизвести последовательность"}
onClick={() => setPlaying((value) => !value)}
>
{playing ? "Пауза" : "Play review"}
</button>
<button
type="button"
aria-label="Предыдущий скан"
disabled={selectedPosition <= 0}
onClick={() => selectPosition(selectedPosition - 1)}
>
</button>
<button
type="button"
aria-label="Следующий скан"
disabled={selectedPosition >= selectedSequence.frames.length - 1}
onClick={() => selectPosition(selectedPosition + 1)}
>
</button>
<input
type="range"
min={0}
max={Math.max(0, selectedSequence.frames.length - 1)}
value={selectedPosition}
aria-label="Позиция в последовательности"
onChange={(event) => selectPosition(Number(event.target.value))}
/>
<span>{selectedPosition + 1} / {selectedSequence.frames.length}</span>
</div>
<div className="polygon-review-stage">
{scene ? (
<LidarGroundPointCloud
key={selectedSequence.datasetSequenceId}
frame={scene}
mode={mode}
coordinateFrame="sensor-fixed"
sensorHeightM={2.24}
/>
) : (
<div className="polygon-review-stage__loading">Загружаем скан</div>
)}
<div className="polygon-review-stage__legend">
<strong>{viewModes.find(([viewMode]) => viewMode === mode)?.[1]}</strong>
<span>{modeExplanation(mode)}</span>
{frameLoading ? <span>Подгружаем выбранный scan</span> : null}
</div>
</div>
{selectedSummary ? (
<div className="polygon-review-frame-metrics">
<article>
<span>Интервал от предыдущего</span>
<strong>{gapFromPrevious === null ? "Начало" : formatGap(gapFromPrevious)}</strong>
<small>реальный timestamp, не cadence playback</small>
</article>
<article>
<span>Current · Ground IoU</span>
<strong>{formatPercent(selectedSummary.current.groundIou)}</strong>
<small>overlay выбранного прогона</small>
</article>
<article>
<span>Patchwork++ · Ground IoU</span>
<strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong>
<small>overlay выбранного прогона</small>
</article>
<article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}>
<span>Разница</span>
<strong>{formatDelta(selectedSummary.groundIouDelta)}</strong>
<small>Patchwork++ минус Current</small>
</article>
</div>
) : null}
<div
className="polygon-review-timeline dataset-sequence-timeline"
aria-label={`Все ${selectedSequence.frames.length} сканов последовательности`}
>
{selectedSequence.frames.map((frame, index) => (
<button
type="button"
key={frame.frameId}
aria-label={`Scan ${index + 1}, source frame ${frame.datasetFrameNumber}`}
title={`${frame.frameId}`}
data-active={frame.frameId === selectedFrameId ? "true" : undefined}
style={{ "--frame-color": "rgb(255 255 255 / 0.34)" } as CSSProperties}
onClick={() => selectPosition(index)}
/>
))}
</div>
<p className="polygon-review-timeline-note">
Риски показывают только доступные размеченные scans этой поездки.
Пропуски времени не интерполируются; ракурс сохраняется между соседними scans.
</p>
</section>
<p className="dataset-sequence-review__raw-note">
Для настоящего движения машины, odometry и синхронного sensor playback нужен
отдельный raw ROS bag GOOSE с localization. Он не входит в принятый 3.3 ГБ
annotated validation ZIP и сейчас не притворяется доступным.
</p>
</section>
);
}

View File

@ -30,6 +30,8 @@ export interface LidarGroundPointCloudFrame {
interface LidarGroundPointCloudProps {
frame: LidarGroundPointCloudFrame;
mode: LidarGroundViewMode;
coordinateFrame?: "auto-fit" | "sensor-fixed";
sensorHeightM?: number;
}
function percentile(values: number[], fraction: number): number {
@ -140,6 +142,8 @@ function frameColors(
export function LidarGroundPointCloud({
frame,
mode,
coordinateFrame = "auto-fit",
sensorHeightM = 0,
}: LidarGroundPointCloudProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
@ -149,6 +153,8 @@ export function LidarGroundPointCloud({
const fogRef = useRef<THREE.FogExp2 | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
const viewRadiusRef = useRef(1);
const viewTargetHeightRef = useRef(0.25);
const viewInitializedRef = useRef(false);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
@ -265,6 +271,8 @@ export function LidarGroundPointCloud({
fogRef.current = null;
gridRef.current = null;
viewRadiusRef.current = 1;
viewTargetHeightRef.current = 0.25;
viewInitializedRef.current = false;
};
}, []);
@ -294,12 +302,18 @@ export function LidarGroundPointCloud({
const maximumY = percentile(sampleY, 0.99);
const minimumZ = percentile(sampleZ, 0.01);
const maximumZ = percentile(sampleZ, 0.99);
const centerX = (minimumX + maximumX) / 2;
const centerY = (minimumY + maximumY) / 2;
const centerX = coordinateFrame === "sensor-fixed"
? 0
: (minimumX + maximumX) / 2;
const centerY = coordinateFrame === "sensor-fixed"
? 0
: (minimumY + maximumY) / 2;
frame.pointsXyzM.forEach(([x, y, z], index) => {
const offset = index * 3;
positions[offset] = x - centerX;
positions[offset + 1] = z - minimumZ;
positions[offset + 1] = coordinateFrame === "sensor-fixed"
? z + sensorHeightM
: z - minimumZ;
positions[offset + 2] = -(y - centerY);
});
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
@ -317,15 +331,21 @@ export function LidarGroundPointCloud({
fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035);
grid.scale.setScalar(Math.max(radius / 5, 0.25));
const targetHeight = Math.max((maximumZ - minimumZ) * 0.42, 0.15);
const targetHeight = coordinateFrame === "sensor-fixed"
? Math.max(sensorHeightM * 0.35, 0.35)
: Math.max((maximumZ - minimumZ) * 0.42, 0.15);
const distance = Math.max(radius * 1.15, 0.9);
controls.target.set(0, targetHeight, 0);
camera.position.set(distance, distance * 0.72, distance);
viewTargetHeightRef.current = targetHeight;
camera.near = Math.max(distance / 1_000, 0.005);
camera.far = Math.max(distance * 100, 100);
camera.updateProjectionMatrix();
if (coordinateFrame === "auto-fit" || !viewInitializedRef.current) {
controls.target.set(0, targetHeight, 0);
camera.position.set(distance, distance * 0.72, distance);
viewInitializedRef.current = true;
}
controls.update();
}, [frame]);
}, [coordinateFrame, frame, sensorHeightM]);
useEffect(() => {
const geometry = geometryRef.current;
@ -345,7 +365,7 @@ export function LidarGroundPointCloud({
const radius = Math.max(viewRadiusRef.current, 0.2);
const distance = Math.max(radius * 1.15, 0.9);
camera.position.set(distance, distance * 0.72, distance);
controls.target.set(0, radius * 0.25, 0);
controls.target.set(0, viewTargetHeightRef.current, 0);
controls.update();
};

View File

@ -1,20 +1,9 @@
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { useEffect, useState } from "react";
import { Button, StatusBadge } from "@nodedc/ui-react";
import {
fetchGroundQualification,
fetchGroundReview,
fetchGroundReviewFrame,
type GroundQualification,
type GroundReview,
type GroundReviewFrame,
type GroundReviewFrameSummary,
} from "../core/polygon/groundQualification";
import {
fetchPolygonRunCatalog,
@ -24,17 +13,11 @@ import {
type PolygonRunRoute,
type PolygonRunState,
} from "../core/polygon/runArchive";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
interface PolygonRunWorkspaceProps {
route: PolygonRunRoute;
}
type ReviewOrder = "sequence" | "best" | "worst";
const stateLabels: Record<PolygonRunState, string> = {
admitted: "Допущен",
starting: "Запускается",
@ -46,21 +29,6 @@ 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" {
@ -71,6 +39,17 @@ function stateTone(
return "neutral";
}
function runKindLabel(kind: string): string {
if (kind === "simulation-closed-loop" || kind === "simulation_closed_loop") {
return "Closed-loop simulation";
}
if (kind === "dataset-replay" || kind === "replay_shadow") {
return "Dataset replay";
}
if (kind === "shadow") return "Live shadow";
return kind;
}
function formatTimestamp(value: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat("ru-RU", {
@ -88,11 +67,6 @@ function formatBytes(value: number): string {
})} ${units[exponent]}`;
}
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return "Не удалось прочитать прогон.";
}
function formatPercent(value: number): string {
return new Intl.NumberFormat("ru-RU", {
style: "percent",
@ -107,74 +81,19 @@ function formatDelta(value: number): string {
})} п.п.`;
}
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
));
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return "Не удалось прочитать прогон.";
}
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
const [qualification, setQualification] = useState<GroundQualification | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(route.runId);
const [loading, setLoading] = useState(route.error === null);
const [error, setError] = useState<string | null>(route.error);
const [reloadGeneration, setReloadGeneration] = useState(0);
const [qualification, setQualification] = useState<GroundQualification | null>(null);
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) {
@ -190,20 +109,30 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal });
if (controller.signal.aborted) return;
setCatalog(nextCatalog);
const targetRunId = selectedRunId ?? route.runId ?? nextCatalog.items[0]?.runId ?? null;
const targetRunId = selectedRunId
?? route.runId
?? nextCatalog.items[0]?.runId
?? null;
if (!targetRunId) {
setDetail(null);
setQualification(null);
return;
}
const nextDetail = await fetchPolygonRunDetail(targetRunId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
const nextQualification = await fetchGroundQualification(targetRunId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
setSelectedRunId(targetRunId);
setDetail(nextDetail);
setQualification(nextQualification);
} catch (loadError) {
if (controller.signal.aborted) return;
setDetail(null);
setQualification(null);
setError(errorMessage(loadError));
} finally {
if (!controller.signal.aborted) setLoading(false);
@ -212,135 +141,13 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
return () => controller.abort();
}, [reloadGeneration, route.error, route.runId, selectedRunId]);
useEffect(() => {
const runId = detail?.run.runId;
setQualification(null);
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(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 = review?.runId;
if (!runId || !selectedFrameId) {
setReviewFrame(null);
return;
}
const cached = frameCache.current.get(selectedFrameId);
if (cached) {
setReviewFrame(cached);
return;
}
const controller = new AbortController();
setReviewFrame(null);
void fetchGroundReviewFrame(runId, selectedFrameId, {
signal: controller.signal,
})
.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) setReviewError(errorMessage(loadError));
});
return () => controller.abort();
}, [review?.runId, selectedFrameId]);
const orderedFrames = useMemo(
() => review ? orderFrames(review, reviewOrder) : [],
[review, reviewOrder],
);
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">
<section className="polygon-run-message">
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<h2>Открываем запись</h2>
<p>Читаем индекс кадров и готовим покадровый просмотр.</p>
<h2>Открываем историю прогонов</h2>
<p>Читаем каталог и выбранный воспроизводимый запуск.</p>
</section>
</div>
);
@ -370,8 +177,8 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
<div className="standard-workspace polygon-run-workspace">
<section className="polygon-run-message">
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<h2>Записей пока нет</h2>
<p>Здесь появятся replay- и simulation-прогоны после публикации результатов.</p>
<h2>Прогонов пока нет</h2>
<p>Здесь появятся simulation, dataset replay и shadow-запуски.</p>
</section>
</div>
);
@ -380,195 +187,75 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const { run } = detail;
return (
<div className="standard-workspace polygon-run-workspace">
<section className="polygon-review-lead">
<section className="polygon-run-picker">
<div>
<span className="section-eyebrow">ПОЛИГОН / ЗАПИСЬ ДАТАСЕТА</span>
<h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
<p>
{qualification
? `${qualification.frameCount} кадров · Current и Patchwork++ против публичной разметки`
: "Архивный прогон без покадрового perception review."}
</p>
<span className="section-eyebrow">ИСТОРИЯ ПОЛИГОНА</span>
<strong>Выбранный прогон</strong>
</div>
<div>
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
{catalog && catalog.items.length > 1 ? (
<select
aria-label="Выбрать прогон"
value={run.runId}
disabled={!catalog?.items.length}
onChange={(event) => setSelectedRunId(event.target.value)}
>
{catalog.items.map((item) => (
<option key={item.runId} value={item.runId}>{item.runId}</option>
{(catalog?.items ?? []).map((item) => (
<option key={item.runId} value={item.runId}>
{runKindLabel(item.kind)} · {item.scenarioGeneration} · {item.runId}
</option>
))}
</select>
) : null}
</div>
<small>{catalog?.total ?? 0} в архиве</small>
</section>
{qualification && review ? (
<>
<section className="polygon-review-player" aria-label="Покадровый просмотр GOOSE">
<header className="polygon-review-player__header">
<section className="polygon-review-lead">
<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-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-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-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, красная
ухудшение. Можно нажать на любую и проверить результат вручную.
<span className="section-eyebrow">{runKindLabel(run.kind).toUpperCase()}</span>
<h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
<p>
{qualification
? "Результат алгоритмического replay. Сам исходный датасет и его sequences открываются в разделе «Датасеты»."
: "Воспроизводимая запись запуска Полигона."}
</p>
</div>
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
</section>
{qualification ? (
<>
<section className="polygon-run-input">
<div>
<span className="section-eyebrow">ВХОД ПРОГОНА</span>
<h3>GOOSE 3D · validation</h3>
<p>
Восемь отдельных поездок, 961 разреженный размеченный LiDAR scan.
Это не closed-loop simulation и не непрерывная запись движения машины.
</p>
</div>
<Button
size="compact"
variant="secondary"
onClick={() => window.location.assign(
`/?workspace=polygon-datasets&run=${encodeURIComponent(run.runId)}`,
)}
>
Открыть исходник
</Button>
</section>
<section className="polygon-review-summary">
<header>
<div>
<span className="section-eyebrow">ВЕСЬ VALIDATION SPLIT</span>
<h3>Что именно сравнивалось</h3>
<span className="section-eyebrow">РЕЗУЛЬТАТ ПРОГОНА</span>
<h3>Current против Patchwork++</h3>
</div>
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
{qualification.decision.passed ? "27 / 27 проверок" : "Есть проваленные проверки"}
{qualification.decision.passed ? "27 / 27 проверок" : "Есть провалы"}
</StatusBadge>
</header>
<p>
На каждом из {qualification.frameCount} нативных LiDAR-кадров оба алгоритма
независимо решали одну задачу: какие точки являются землёй. Их ответы
сравнивались с публичной point-aligned разметкой GOOSE.
Оба алгоритма независимо обработали одни и те же 961 scans.
Покадровая визуальная проверка теперь находится рядом с самим входом
в «Датасетах», а не притворяется симуляцией машины.
</p>
<div>
<article>
@ -597,16 +284,20 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
</article>
<article>
<span>Latency p95</span>
<strong>{formatMilliseconds(qualification.current.latencyMs.p95)}</strong>
<strong>{qualification.current.latencyMs.p95.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} мс</strong>
<i></i>
<strong>{formatMilliseconds(qualification.patchwork.latencyMs.p95)}</strong>
<small>на кадр</small>
<strong>{qualification.patchwork.latencyMs.p95.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} мс</strong>
<small>на scan</small>
</article>
</div>
</section>
<details className="polygon-review-analysis">
<summary>Полные метрики и деградации</summary>
<summary>Acceptance и деградации</summary>
<div className="polygon-review-analysis__content">
<section>
<span className="section-eyebrow">ACCEPTANCE GATES</span>
@ -629,28 +320,20 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
<strong>{profileId}</strong>
<span>IoU {formatPercent(aggregate.micro.groundIou)}</span>
<span>Natural {formatPercent(aggregate.micro.naturalGroundRecall)}</span>
<span>p95 {formatMilliseconds(aggregate.latencyMs.p95)}</span>
<span>p95 {aggregate.latencyMs.p95.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} мс</span>
</p>
))}
</section>
</div>
</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>
<h3>Для этого запуска нет perception qualification</h3>
<p>Техническая идентичность доступна ниже.</p>
</section>
)}
@ -659,6 +342,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
<div className="polygon-run-technical__content">
<dl>
<div><dt>Run ID</dt><dd>{run.runId}</dd></div>
<div><dt>Тип</dt><dd>{runKindLabel(run.kind)}</dd></div>
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
<div><dt>Mission Core</dt><dd>{run.missionCoreCommit.slice(0, 12)}</dd></div>

View File

@ -10,6 +10,8 @@ let decodeGroundReview;
let decodeGroundReviewFrame;
let fetchGroundQualification;
let GroundQualificationContractError;
let parseGooseFrameId;
let groupGooseReviewFrames;
const aggregate = {
micro: {
@ -76,7 +78,11 @@ before(async () => {
decodeGroundReviewFrame,
fetchGroundQualification,
GroundQualificationContractError,
parseGooseFrameId,
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
({
groupGooseReviewFrames,
} = await server.ssrLoadModule("/src/workspaces/GooseDatasetReview.tsx"));
});
after(async () => {
@ -139,7 +145,7 @@ test("treats missing qualification as a generic run", async () => {
test("decodes a complete ordered frame review", () => {
const decoded = decodeGroundReview({
schema_version: "missioncore.polygon-ground-review/v1",
schema_version: "missioncore.polygon-ground-review/v2",
access: "read-only",
run_id: "goose-ground-abc",
identity_sha256: "b".repeat(64),
@ -148,7 +154,10 @@ test("decodes a complete ordered frame review", () => {
preview_points: 12_000,
frames: [0, 1].map((sequence) => ({
sequence,
frame_id: `2022-07-22_flight__0071_000${sequence}`,
frame_id: `2022-07-22_flight__007${sequence + 1}_165849423${sequence}334310308`,
dataset_sequence_id: "2022-07-22_flight",
dataset_frame_number: sequence + 71,
sensor_timestamp_ns: `165849423${sequence}334310308`,
source_point_count: 100_000,
point_count: 12_000,
current: {
@ -168,9 +177,59 @@ test("decodes a complete ordered frame review", () => {
});
assert.equal(decoded.frames.length, 2);
assert.equal(decoded.frames[1].sequence, 1);
assert.equal(decoded.frames[1].datasetSequenceId, "2022-07-22_flight");
assert.equal(decoded.frames[1].datasetFrameNumber, 72);
assert.equal(decoded.frames[0].patchwork.groundIou, 0.7);
});
test("parses GOOSE sequence, source frame and nanosecond timestamp", () => {
assert.deepEqual(
parseGooseFrameId("2023-05-17_neubiberg_sunny__0459_1684330012686564560"),
{
datasetSequenceId: "2023-05-17_neubiberg_sunny",
datasetFrameNumber: 459,
sensorTimestampNs: "1684330012686564560",
},
);
});
test("keeps independent GOOSE recordings in separate playback sequences", () => {
const metric = {
groundIou: 0.5,
naturalGroundRecall: 0.6,
obstacleNonGroundRecall: 0.9,
latencyMs: 20,
};
const frames = [
["2022-07-22_flight", 71, "1658494234334310308"],
["2022-07-22_flight", 72, "1658494235988100385"],
["2023-05-17_neubiberg_sunny", 459, "1684330012686564560"],
].map(([datasetSequenceId, datasetFrameNumber, sensorTimestampNs], sequence) => ({
sequence,
frameId: `${datasetSequenceId}__${String(datasetFrameNumber).padStart(4, "0")}_${sensorTimestampNs}`,
datasetSequenceId,
datasetFrameNumber,
sensorTimestampNs,
sourcePointCount: 100_000,
pointCount: 12_000,
current: metric,
patchwork: metric,
groundIouDelta: 0,
}));
const groups = groupGooseReviewFrames({
runId: "goose-ground-abc",
identitySha256: "b".repeat(64),
sourceId: "goose-3d/v2025-08-22",
frameCount: frames.length,
previewPoints: 12_000,
frames,
});
assert.equal(groups.length, 2);
assert.equal(groups[0].frames.length, 2);
assert.equal(groups[1].frames.length, 1);
assert.ok(groups[0].durationSeconds > 1.6);
});
test("decodes a point-aligned review frame with intensity", () => {
const decoded = decodeGroundReviewFrame({
schema_version: "missioncore.polygon-ground-review-frame/v1",

View File

@ -847,10 +847,11 @@ show an explicit offline state.
The Polygon navigation is task-oriented:
- `Прогоны` owns recorded replay/simulation history and operator review of
qualification evidence;
- `Датасеты` owns admitted replay inputs and the entry point for a future
qualification run.
- `Прогоны` owns selectable recorded simulation/dataset-replay/shadow history,
aggregate outcomes, provenance and decisions;
- `Датасеты` owns admitted replay inputs, their real source structure and
operator inspection of source scans. A selected run may be overlaid on the
source, but the source does not move into the run archive.
Dataset bytes and source artifacts remain in Data storage, but algorithm
evaluation does not live under `Данные`. Device-specific scanner diagnostics
@ -864,21 +865,30 @@ They require a separate live surface that appears only when the Simulation
Worker capability is available. The current `Прогоны` implementation is
read-only and remains fully usable while that worker is offline.
### UI-0 — early read-only run view
### UI-0 — read-only run history and dataset inspection
Target S1B proves persisted start/health/stop history. The first direct
developer-oriented view exposed lifecycle, events, provider pins and artifact
hashes on the main screen. Operator review rejected that composition. The
current UI-0 makes the recorded evidence primary:
hashes on the main screen. Operator review rejected that composition and then
rejected treating the GOOSE validation split as a single recording. The
current UI-0 follows two separate operator tasks:
- full source-order frame index instead of five selected examples;
- Play/pause, previous/next and arbitrary scrub;
- direct ground-truth, Current, Patchwork++ and error-map modes;
- current-frame metrics and a clickable all-frame improvement/regression strip;
- automatic best/worst ordering without preventing arbitrary manual selection;
- aggregate comparison after, rather than before, visual evidence;
- acceptance/degradation and run identity/provider/artifact details collapsed
below the operator workflow.
- `Прогоны` always shows a run selector, even when the archive currently has
only one item. The selected run exposes its kind (`simulation`,
`dataset-replay` or `shadow`), input identity, aggregate result and collapsed
technical evidence.
- `Датасеты → Открыть источник` shows the admitted source. GOOSE validation is
explicitly grouped into eight independent sequences; Play is bounded to one
sequence and described as accelerated review of sparse annotated scans.
- The dataset viewer exposes source frame number, nanosecond timestamp gaps,
ground truth and optional Current/Patchwork++ overlays from a selected run.
- The fixed sensor-centric coordinate frame and operator camera are preserved
within a sequence. Missing scans, ego trajectory and cross-sequence motion
are never synthesized.
The accepted `3.3 GB` annotated validation ZIP is not a continuous recording.
Continuous ego-motion requires a separately admitted raw ROS bag and
localization source.
The backend reads the configured repository from
`MISSIONCORE_POLYGON_RUNS_ROOT` using

View File

@ -65,9 +65,12 @@ The gateway is not part of device quality diagnostics.
dataset frames into the selected device evidence.
- **Наблюдение** opens a concrete live or recorded spatial scene.
- **Данные** owns source-of-record, retention, replay preparation and export.
- **Полигон → Датасеты** lists admitted evaluation inputs.
- **Полигон → Датасеты** lists admitted evaluation inputs and owns source
inspection: source sequences, sparse annotated scans and optional overlays
from a selected qualification run.
- **Полигон → Прогоны** owns the resulting algorithm comparison, metrics,
provenance and decision.
provenance and decision. It does not present the dataset itself as a
simulation or continuous vehicle recording.
Before real dataset bytes exist, the catalog explains the blocked storage gate
and keeps `Открыть` / `Создать прогон` disabled. `Открыть` becomes available
@ -341,9 +344,37 @@ qualification report:
- deterministic `12,000`-point frame previews with remission, public
point-aligned ground truth, Current and Patchwork++ masks;
- compact size `67 MB`, versus multiple gigabytes for equivalent JSON;
- `Play`, frame scrubber, a clickable all-frame delta strip, best/worst
ordering and direct modes for geometry, ground truth, both providers and
both error maps.
- direct modes for geometry, ground truth, both providers and both error maps.
The subsequent product review found a second semantic error: the `961` frames
were presented as one playable recording. They are actually `8` independent
GOOSE validation sequences:
- `2022-07-22_flight``151` annotated scans;
- `2022-08-30_siegertsbrunn_feldwege``103`;
- `2022-09-21_garching_uebungsplatz_2``123`;
- `2022-12-07_aying_hills``133`;
- `2023-01-20_aying_mangfall_2``191`;
- `2023-03-03_garching_2``106`;
- `2023-05-15_neubiberg_rain``73`;
- `2023-05-17_neubiberg_sunny``81`.
Each admitted `.bin` is one annotated VLS-128 revolution. Within those eight
sequences the validation scans are sparse: observed adjacent timestamps range
from about `0.1 s` to `167.5 s`. The annotated ZIP contains neither a
continuous playback stream nor a pose chain that could support visually
continuous ego-motion. Mission Core therefore:
- never plays across a sequence boundary;
- labels Play as an accelerated scan review, not real-time playback;
- exposes the exact source frame number and timestamp gap;
- keeps a fixed sensor-centric origin and preserves the operator camera within
one sequence instead of refitting every frame;
- does not interpolate missing scans or invent a route.
Continuous vehicle motion, odometry and synchronized sensor playback require
the separately admitted raw GOOSE ROS bags plus localization. Those assets are
not part of the current `3.3 GB` annotated validation archive.
The source archive and full-resolution crash-resume cache remain only on worker
D. A bounded review mirror may be served by a Mission Core backend, but SSH is

View File

@ -44,7 +44,7 @@ GROUND_REPORT_ARTIFACT_KIND: Final = "goose-ground-qualification-report"
GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview"
GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1"
GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1"
GROUND_REVIEW_SCHEMA: Final = "missioncore.polygon-ground-review/v1"
GROUND_REVIEW_SCHEMA: Final = "missioncore.polygon-ground-review/v2"
GROUND_REVIEW_FRAME_SCHEMA: Final = "missioncore.polygon-ground-review-frame/v1"
GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1"
GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/v1"
@ -54,6 +54,11 @@ MAX_REVIEW_FRAME_BYTES: Final = 2 * 1024**2
MAX_REVIEW_FRAMES: Final = 2_000
MAX_REVIEW_POINTS: Final = 20_000
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
GOOSE_FRAME_ID_PATTERN: Final = re.compile(
r"^(?P<dataset_sequence_id>[A-Za-z0-9][A-Za-z0-9_-]{0,95})"
r"__(?P<dataset_frame_number>[0-9]{4})"
r"_(?P<sensor_timestamp_ns>[0-9]{16,20})$"
)
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"Архивный UI-0 публикует только квалификационные доказательства; "
"содержимое команд и mutation-маршруты в архиве отсутствуют.",
@ -288,6 +293,7 @@ def build_polygon_router(
"preview_points": manifest["preview_points"],
"frames": [
{
**{
key: frame[key]
for key in (
"sequence",
@ -298,6 +304,8 @@ def build_polygon_router(
"patchworkpp",
"ground_iou_delta",
)
},
**_goose_frame_identity(frame["frame_id"]),
}
for frame in manifest["frames"]
],
@ -717,6 +725,21 @@ def _read_ground_review_manifest(
return pack_root, manifest
def _goose_frame_identity(frame_id: str) -> dict[str, str | int]:
match = GOOSE_FRAME_ID_PATTERN.fullmatch(frame_id)
if match is None:
raise HTTPException(
status_code=500,
detail="Идентичность GOOSE-кадра нарушает контракт.",
)
return {
"dataset_sequence_id": match.group("dataset_sequence_id"),
"dataset_frame_number": int(match.group("dataset_frame_number")),
# Nanosecond Unix time exceeds JavaScript's safe integer range.
"sensor_timestamp_ns": match.group("sensor_timestamp_ns"),
}
def _valid_review_metrics(value: object) -> bool:
if not isinstance(value, dict):
return False

View File

@ -214,7 +214,7 @@ def _qualification_run(root: Path) -> tuple[QualificationRun, str]:
evidence = root / admitted.run_id / "evidence"
failures = evidence / "failures"
failures.mkdir(parents=True)
frame_id = "2022-07-22_flight__0071_0001"
frame_id = "2022-07-22_flight__0071_1658494234334310308"
report = {
"schema_version": "missioncore.goose-ground-qualification-report/v1",
"identity_sha256": SHA_A,
@ -403,9 +403,12 @@ def test_polygon_api_publishes_all_frame_review_without_paths(tmp_path: Path) ->
"GET",
)(run_id=run.run_id, frame_id=frame_id)
assert review["schema_version"] == "missioncore.polygon-ground-review/v1"
assert review["schema_version"] == "missioncore.polygon-ground-review/v2"
assert review["frame_count"] == 1
assert review["frames"][0]["ground_iou_delta"] == pytest.approx(0.2)
assert review["frames"][0]["dataset_sequence_id"] == "2022-07-22_flight"
assert review["frames"][0]["dataset_frame_number"] == 71
assert review["frames"][0]["sensor_timestamp_ns"] == "1658494234334310308"
assert "relative_path" not in review["frames"][0]
assert frame["schema_version"] == "missioncore.polygon-ground-review-frame/v1"
assert frame["points_xyz_m"] == [[0.0, 0.0, 0.0], [1.0, -1.0, 0.5]]