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 { export interface GroundReviewFrameSummary {
sequence: number; sequence: number;
frameId: string; frameId: string;
datasetSequenceId: string;
datasetFrameNumber: number;
sensorTimestampNs: string;
sourcePointCount: number; sourcePointCount: number;
pointCount: number; pointCount: number;
current: GroundReviewFrameMetrics; current: GroundReviewFrameMetrics;
@ -108,6 +111,8 @@ type QualificationFetch = (
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const SHA256 = /^[a-f0-9]{64}$/; 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> { function record(value: unknown, field: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) { 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 { export function decodeGroundReview(payload: unknown): GroundReview {
const source = record(payload, "ground review"); const source = record(payload, "ground review");
if ( if (
source.schema_version !== "missioncore.polygon-ground-review/v1" source.schema_version !== "missioncore.polygon-ground-review/v2"
|| source.access !== "read-only" || source.access !== "read-only"
) { ) {
throw new GroundQualificationContractError("Неизвестная схема покадрового просмотра."); throw new GroundQualificationContractError("Неизвестная схема покадрового просмотра.");
@ -386,9 +391,36 @@ export function decodeGroundReview(payload: unknown): GroundReview {
if (sequence !== index) { if (sequence !== index) {
throw new GroundQualificationContractError("Кадры просмотра идут не по порядку."); 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 { return {
sequence, sequence,
frameId: id(frame.frame_id, `frames[${index}].frame_id`), frameId,
datasetSequenceId,
datasetFrameNumber,
sensorTimestampNs,
sourcePointCount: integer( sourcePointCount: integer(
frame.source_point_count, frame.source_point_count,
`frames[${index}].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 { export function decodeGroundReviewFrame(payload: unknown): GroundReviewFrame {
const source = record(payload, "ground review frame"); const source = record(payload, "ground review frame");
if ( if (

View File

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

View File

@ -97,6 +97,10 @@
} }
@media (max-width: 1040px) { @media (max-width: 1040px) {
.dataset-sequence-review__truth {
grid-template-columns: 1fr;
}
.polygon-review-summary > div:last-child, .polygon-review-summary > div:last-child,
.polygon-review-analysis__content, .polygon-review-analysis__content,
.polygon-run-technical__content { .polygon-run-technical__content {
@ -213,6 +217,20 @@
grid-template-columns: 1fr; 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 { .polygon-review-controls {
grid-template-columns: auto auto auto minmax(0, 1fr); grid-template-columns: auto auto auto minmax(0, 1fr);
} }

View File

@ -1023,6 +1023,84 @@
padding: 0.15rem 0.1rem 0.25rem; 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 h2,
.polygon-review-lead p { .polygon-review-lead p {
margin: 0; margin: 0;
@ -1064,6 +1142,91 @@
background: var(--station-panel); 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 { .polygon-review-player__header {
display: flex; display: flex;
min-width: 0; min-width: 0;

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { import {
Button, Button,
GlassSurface, GlassSurface,
@ -7,16 +7,13 @@ import {
import { import {
fetchDatasetGatewayCatalog, fetchDatasetGatewayCatalog,
fetchDatasetGroundComparison,
fetchDatasetNativeScanPreview,
type DatasetGatewayCatalog, type DatasetGatewayCatalog,
type DatasetGroundComparison,
type DatasetNativeScanPreview,
} from "../core/lidar/datasetGateway"; } from "../core/lidar/datasetGateway";
import { import {
LidarGroundPointCloud, fetchPolygonRunCatalog,
type LidarGroundViewMode, type PolygonRunCatalog,
} from "./LidarGroundPointCloud"; } from "../core/polygon/runArchive";
import { GooseDatasetReview } from "./GooseDatasetReview";
const representationLabels: Record<string, string> = { const representationLabels: Record<string, string> = {
"native-scan": "Исходный скан", "native-scan": "Исходный скан",
@ -51,18 +48,22 @@ function formatBytes(value: number): string {
}).format(value / 1_000_000_000); }).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() { export function DatasetGatewayWorkspace() {
const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null); const [catalog, setCatalog] = useState<DatasetGatewayCatalog | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [reloadGeneration, setReloadGeneration] = useState(0); const [reloadGeneration, setReloadGeneration] = useState(0);
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [preview, setPreview] = useState<DatasetNativeScanPreview | null>(null); const [runCatalog, setRunCatalog] = useState<PolygonRunCatalog | null>(null);
const [comparison, setComparison] = useState<DatasetGroundComparison | null>(null); const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null); const [runError, setRunError] = useState<string | null>(null);
const [comparisonError, setComparisonError] = useState<string | null>(null);
const [previewMode, setPreviewMode] =
useState<LidarGroundViewMode>("semantic");
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@ -93,64 +94,29 @@ export function DatasetGatewayWorkspace() {
}, [catalog]); }, [catalog]);
useEffect(() => { useEffect(() => {
if (!previewOpen || preview) return;
const controller = new AbortController(); const controller = new AbortController();
setPreviewError(null); setRunError(null);
void fetchDatasetNativeScanPreview({ signal: controller.signal }) void fetchPolygonRunCatalog({ signal: controller.signal })
.then((value) => { .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) => { .catch((loadError: unknown) => {
if (!controller.signal.aborted) setPreviewError(errorMessage(loadError)); if (!controller.signal.aborted) setRunError(errorMessage(loadError));
}); });
return () => controller.abort(); 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 const downloadProgress = catalog?.source.archive
? catalog.source.archive.bytesTransferred / catalog.source.archive.totalBytes ? catalog.source.archive.bytesTransferred / catalog.source.archive.totalBytes
: null; : null;
const gooseRuns = runCatalog?.items.filter(isGooseQualificationRun) ?? [];
return ( return (
<div className="standard-workspace dataset-workspace"> <div className="standard-workspace dataset-workspace">
@ -213,9 +179,9 @@ export function DatasetGatewayWorkspace() {
<span>PUBLIC · OFF-ROAD · LABELED</span> <span>PUBLIC · OFF-ROAD · LABELED</span>
<h3>{catalog.source.displayName}</h3> <h3>{catalog.source.displayName}</h3>
<p> <p>
Один оборот VLS-128 с point-wise semantic и instance labels. Validation split: восемь отдельных поездок и 961 разреженный
Первый baseline для ground segmentation и последующих размеченный оборот VLS-128. Это коллекция native scans для
LiDAR-native моделей. perception, а не непрерывное видео или траектория машины.
</p> </p>
</div> </div>
</div> </div>
@ -266,12 +232,12 @@ export function DatasetGatewayWorkspace() {
disabled={catalog.source.admissionStatus !== "frame-ready"} disabled={catalog.source.admissionStatus !== "frame-ready"}
title={ title={
catalog.source.admissionStatus === "frame-ready" catalog.source.admissionStatus === "frame-ready"
? "Открыть первый размеченный native scan" ? "Открыть все доступные последовательности и scans"
: "Первый реальный кадр ещё не импортирован" : "Размеченные scans ещё не импортированы"
} }
onClick={() => setPreviewOpen((value) => !value)} onClick={() => setPreviewOpen((value) => !value)}
> >
{previewOpen ? "Закрыть" : "Открыть"} {previewOpen ? "Закрыть источник" : "Открыть источник"}
</Button> </Button>
<Button <Button
size="compact" size="compact"
@ -286,145 +252,34 @@ export function DatasetGatewayWorkspace() {
</article> </article>
</GlassSurface> </GlassSurface>
{previewOpen ? ( {previewOpen && selectedRunId ? (
<section className="dataset-preview" aria-label="Первый GOOSE native scan"> <>
<header> {gooseRuns.length > 0 ? (
<div className="dataset-result-select">
<div> <div>
<span className="section-eyebrow">NATIVE SCAN · GROUND TRUTH</span> <span className="section-eyebrow">НАЛОЖЕНИЕ РЕЗУЛЬТАТА</span>
<h2> <strong>Квалификационный прогон</strong>
{preview
? `${preview.frameId} · ${preview.sourcePointCount.toLocaleString("ru-RU")} точек`
: "Первый размеченный кадр"}
</h2>
</div> </div>
<div className="lidar-ground-modes" aria-label="Режим окраски"> <select
{([ aria-label="Выбрать прогон для наложения"
["semantic", "Классы"], value={selectedRunId}
["ground-truth", "Ground"], onChange={(event) => setSelectedRunId(event.target.value)}
...(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)}
> >
{label} {gooseRuns.map((item) => (
</button> <option key={item.runId} value={item.runId}>
{item.scenarioGeneration} · {item.runId}
</option>
))} ))}
</select>
</div> </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} ) : null}
{previewFrame ? ( <GooseDatasetReview runId={selectedRunId} />
<>
<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>
</> </>
) : previewMode === "current" ? ( ) : previewOpen ? (
<> <section className="polygon-review-unavailable">
<span><i className="dataset-legend-ground" />current ground</span> <StatusBadge tone="warning">Review недоступен</StatusBadge>
<span><i className="dataset-legend-other" />current non-ground</span> <h3>Источник принят, но покадровый preview не опубликован</h3>
</> <p>{runError ?? "Нет квалификационного прогона с bounded frame-pack."}</p>
) : 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}
</section> </section>
) : null} ) : 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 { interface LidarGroundPointCloudProps {
frame: LidarGroundPointCloudFrame; frame: LidarGroundPointCloudFrame;
mode: LidarGroundViewMode; mode: LidarGroundViewMode;
coordinateFrame?: "auto-fit" | "sensor-fixed";
sensorHeightM?: number;
} }
function percentile(values: number[], fraction: number): number { function percentile(values: number[], fraction: number): number {
@ -140,6 +142,8 @@ function frameColors(
export function LidarGroundPointCloud({ export function LidarGroundPointCloud({
frame, frame,
mode, mode,
coordinateFrame = "auto-fit",
sensorHeightM = 0,
}: LidarGroundPointCloudProps) { }: LidarGroundPointCloudProps) {
const hostRef = useRef<HTMLDivElement | null>(null); const hostRef = useRef<HTMLDivElement | null>(null);
const geometryRef = useRef<THREE.BufferGeometry | null>(null); const geometryRef = useRef<THREE.BufferGeometry | null>(null);
@ -149,6 +153,8 @@ export function LidarGroundPointCloud({
const fogRef = useRef<THREE.FogExp2 | null>(null); const fogRef = useRef<THREE.FogExp2 | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null); const gridRef = useRef<THREE.GridHelper | null>(null);
const viewRadiusRef = useRef(1); const viewRadiusRef = useRef(1);
const viewTargetHeightRef = useRef(0.25);
const viewInitializedRef = useRef(false);
const [renderError, setRenderError] = useState<string | null>(null); const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@ -265,6 +271,8 @@ export function LidarGroundPointCloud({
fogRef.current = null; fogRef.current = null;
gridRef.current = null; gridRef.current = null;
viewRadiusRef.current = 1; viewRadiusRef.current = 1;
viewTargetHeightRef.current = 0.25;
viewInitializedRef.current = false;
}; };
}, []); }, []);
@ -294,12 +302,18 @@ export function LidarGroundPointCloud({
const maximumY = percentile(sampleY, 0.99); const maximumY = percentile(sampleY, 0.99);
const minimumZ = percentile(sampleZ, 0.01); const minimumZ = percentile(sampleZ, 0.01);
const maximumZ = percentile(sampleZ, 0.99); const maximumZ = percentile(sampleZ, 0.99);
const centerX = (minimumX + maximumX) / 2; const centerX = coordinateFrame === "sensor-fixed"
const centerY = (minimumY + maximumY) / 2; ? 0
: (minimumX + maximumX) / 2;
const centerY = coordinateFrame === "sensor-fixed"
? 0
: (minimumY + maximumY) / 2;
frame.pointsXyzM.forEach(([x, y, z], index) => { frame.pointsXyzM.forEach(([x, y, z], index) => {
const offset = index * 3; const offset = index * 3;
positions[offset] = x - centerX; positions[offset] = x - centerX;
positions[offset + 1] = z - minimumZ; positions[offset + 1] = coordinateFrame === "sensor-fixed"
? z + sensorHeightM
: z - minimumZ;
positions[offset + 2] = -(y - centerY); positions[offset + 2] = -(y - centerY);
}); });
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); 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); fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035);
grid.scale.setScalar(Math.max(radius / 5, 0.25)); 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); const distance = Math.max(radius * 1.15, 0.9);
controls.target.set(0, targetHeight, 0); viewTargetHeightRef.current = targetHeight;
camera.position.set(distance, distance * 0.72, distance);
camera.near = Math.max(distance / 1_000, 0.005); camera.near = Math.max(distance / 1_000, 0.005);
camera.far = Math.max(distance * 100, 100); camera.far = Math.max(distance * 100, 100);
camera.updateProjectionMatrix(); 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(); controls.update();
}, [frame]); }, [coordinateFrame, frame, sensorHeightM]);
useEffect(() => { useEffect(() => {
const geometry = geometryRef.current; const geometry = geometryRef.current;
@ -345,7 +365,7 @@ export function LidarGroundPointCloud({
const radius = Math.max(viewRadiusRef.current, 0.2); const radius = Math.max(viewRadiusRef.current, 0.2);
const distance = Math.max(radius * 1.15, 0.9); const distance = Math.max(radius * 1.15, 0.9);
camera.position.set(distance, distance * 0.72, distance); 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(); controls.update();
}; };

View File

@ -1,20 +1,9 @@
import { import { useEffect, useState } from "react";
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { Button, StatusBadge } from "@nodedc/ui-react"; import { Button, StatusBadge } from "@nodedc/ui-react";
import { import {
fetchGroundQualification, fetchGroundQualification,
fetchGroundReview,
fetchGroundReviewFrame,
type GroundQualification, type GroundQualification,
type GroundReview,
type GroundReviewFrame,
type GroundReviewFrameSummary,
} from "../core/polygon/groundQualification"; } from "../core/polygon/groundQualification";
import { import {
fetchPolygonRunCatalog, fetchPolygonRunCatalog,
@ -24,17 +13,11 @@ import {
type PolygonRunRoute, type PolygonRunRoute,
type PolygonRunState, type PolygonRunState,
} from "../core/polygon/runArchive"; } from "../core/polygon/runArchive";
import {
LidarGroundPointCloud,
type LidarGroundViewMode,
} from "./LidarGroundPointCloud";
interface PolygonRunWorkspaceProps { interface PolygonRunWorkspaceProps {
route: PolygonRunRoute; route: PolygonRunRoute;
} }
type ReviewOrder = "sequence" | "best" | "worst";
const stateLabels: Record<PolygonRunState, string> = { const stateLabels: Record<PolygonRunState, string> = {
admitted: "Допущен", admitted: "Допущен",
starting: "Запускается", starting: "Запускается",
@ -46,21 +29,6 @@ const stateLabels: Record<PolygonRunState, string> = {
aborted: "Прерван", 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( function stateTone(
state: PolygonRunState, state: PolygonRunState,
): "success" | "accent" | "warning" | "danger" | "neutral" { ): "success" | "accent" | "warning" | "danger" | "neutral" {
@ -71,6 +39,17 @@ function stateTone(
return "neutral"; 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 { function formatTimestamp(value: string | null): string {
if (!value) return "—"; if (!value) return "—";
return new Intl.DateTimeFormat("ru-RU", { return new Intl.DateTimeFormat("ru-RU", {
@ -88,11 +67,6 @@ function formatBytes(value: number): string {
})} ${units[exponent]}`; })} ${units[exponent]}`;
} }
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message;
return "Не удалось прочитать прогон.";
}
function formatPercent(value: number): string { function formatPercent(value: number): string {
return new Intl.NumberFormat("ru-RU", { return new Intl.NumberFormat("ru-RU", {
style: "percent", style: "percent",
@ -107,74 +81,19 @@ function formatDelta(value: number): string {
})} п.п.`; })} п.п.`;
} }
function formatMilliseconds(value: number): string { function errorMessage(error: unknown): string {
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`; if (error instanceof Error && error.message.trim()) return error.message;
} return "Не удалось прочитать прогон.";
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) { export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null); const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
const [detail, setDetail] = useState<PolygonRunDetail | 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 [selectedRunId, setSelectedRunId] = useState<string | null>(route.runId);
const [loading, setLoading] = useState(route.error === null); const [loading, setLoading] = useState(route.error === null);
const [error, setError] = useState<string | null>(route.error); const [error, setError] = useState<string | null>(route.error);
const [reloadGeneration, setReloadGeneration] = useState(0); 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(() => { useEffect(() => {
if (route.error) { if (route.error) {
@ -190,20 +109,30 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal }); const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal });
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setCatalog(nextCatalog); setCatalog(nextCatalog);
const targetRunId = selectedRunId ?? route.runId ?? nextCatalog.items[0]?.runId ?? null; const targetRunId = selectedRunId
?? route.runId
?? nextCatalog.items[0]?.runId
?? null;
if (!targetRunId) { if (!targetRunId) {
setDetail(null); setDetail(null);
setQualification(null);
return; return;
} }
const nextDetail = await fetchPolygonRunDetail(targetRunId, { const nextDetail = await fetchPolygonRunDetail(targetRunId, {
signal: controller.signal, signal: controller.signal,
}); });
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
const nextQualification = await fetchGroundQualification(targetRunId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
setSelectedRunId(targetRunId); setSelectedRunId(targetRunId);
setDetail(nextDetail); setDetail(nextDetail);
setQualification(nextQualification);
} catch (loadError) { } catch (loadError) {
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setDetail(null); setDetail(null);
setQualification(null);
setError(errorMessage(loadError)); setError(errorMessage(loadError));
} finally { } finally {
if (!controller.signal.aborted) setLoading(false); if (!controller.signal.aborted) setLoading(false);
@ -212,135 +141,13 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
return () => controller.abort(); return () => controller.abort();
}, [reloadGeneration, route.error, route.runId, selectedRunId]); }, [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) { if (loading && !detail) {
return ( return (
<div className="standard-workspace polygon-run-workspace"> <div className="standard-workspace polygon-run-workspace">
<section className="polygon-run-message"> <section className="polygon-run-message">
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span> <span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<h2>Открываем запись</h2> <h2>Открываем историю прогонов</h2>
<p>Читаем индекс кадров и готовим покадровый просмотр.</p> <p>Читаем каталог и выбранный воспроизводимый запуск.</p>
</section> </section>
</div> </div>
); );
@ -370,8 +177,8 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
<div className="standard-workspace polygon-run-workspace"> <div className="standard-workspace polygon-run-workspace">
<section className="polygon-run-message"> <section className="polygon-run-message">
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span> <span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<h2>Записей пока нет</h2> <h2>Прогонов пока нет</h2>
<p>Здесь появятся replay- и simulation-прогоны после публикации результатов.</p> <p>Здесь появятся simulation, dataset replay и shadow-запуски.</p>
</section> </section>
</div> </div>
); );
@ -380,195 +187,75 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const { run } = detail; const { run } = detail;
return ( return (
<div className="standard-workspace polygon-run-workspace"> <div className="standard-workspace polygon-run-workspace">
<section className="polygon-review-lead"> <section className="polygon-run-picker">
<div> <div>
<span className="section-eyebrow">ПОЛИГОН / ЗАПИСЬ ДАТАСЕТА</span> <span className="section-eyebrow">ИСТОРИЯ ПОЛИГОНА</span>
<h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2> <strong>Выбранный прогон</strong>
<p>
{qualification
? `${qualification.frameCount} кадров · Current и Patchwork++ против публичной разметки`
: "Архивный прогон без покадрового perception review."}
</p>
</div> </div>
<div>
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
{catalog && catalog.items.length > 1 ? (
<select <select
aria-label="Выбрать прогон" aria-label="Выбрать прогон"
value={run.runId} value={run.runId}
disabled={!catalog?.items.length}
onChange={(event) => setSelectedRunId(event.target.value)} onChange={(event) => setSelectedRunId(event.target.value)}
> >
{catalog.items.map((item) => ( {(catalog?.items ?? []).map((item) => (
<option key={item.runId} value={item.runId}>{item.runId}</option> <option key={item.runId} value={item.runId}>
{runKindLabel(item.kind)} · {item.scenarioGeneration} · {item.runId}
</option>
))} ))}
</select> </select>
) : null} <small>{catalog?.total ?? 0} в архиве</small>
</div>
</section> </section>
{qualification && review ? ( <section className="polygon-review-lead">
<>
<section className="polygon-review-player" aria-label="Покадровый просмотр GOOSE">
<header className="polygon-review-player__header">
<div> <div>
<span className="section-eyebrow">КАДР {selectedSummary <span className="section-eyebrow">{runKindLabel(run.kind).toUpperCase()}</span>
? selectedSummary.sequence + 1 <h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
: "—"} / {review.frameCount}</span> <p>
<strong>{selectedSummary?.frameId ?? "Загружаем запись"}</strong> {qualification
</div> ? "Результат алгоритмического replay. Сам исходный датасет и его sequences открываются в разделе «Датасеты»."
<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, красная
ухудшение. Можно нажать на любую и проверить результат вручную.
</p> </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>
<section className="polygon-review-summary"> <section className="polygon-review-summary">
<header> <header>
<div> <div>
<span className="section-eyebrow">ВЕСЬ VALIDATION SPLIT</span> <span className="section-eyebrow">РЕЗУЛЬТАТ ПРОГОНА</span>
<h3>Что именно сравнивалось</h3> <h3>Current против Patchwork++</h3>
</div> </div>
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}> <StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
{qualification.decision.passed ? "27 / 27 проверок" : "Есть проваленные проверки"} {qualification.decision.passed ? "27 / 27 проверок" : "Есть провалы"}
</StatusBadge> </StatusBadge>
</header> </header>
<p> <p>
На каждом из {qualification.frameCount} нативных LiDAR-кадров оба алгоритма Оба алгоритма независимо обработали одни и те же 961 scans.
независимо решали одну задачу: какие точки являются землёй. Их ответы Покадровая визуальная проверка теперь находится рядом с самим входом
сравнивались с публичной point-aligned разметкой GOOSE. в «Датасетах», а не притворяется симуляцией машины.
</p> </p>
<div> <div>
<article> <article>
@ -597,16 +284,20 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
</article> </article>
<article> <article>
<span>Latency p95</span> <span>Latency p95</span>
<strong>{formatMilliseconds(qualification.current.latencyMs.p95)}</strong> <strong>{qualification.current.latencyMs.p95.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} мс</strong>
<i></i> <i></i>
<strong>{formatMilliseconds(qualification.patchwork.latencyMs.p95)}</strong> <strong>{qualification.patchwork.latencyMs.p95.toLocaleString("ru-RU", {
<small>на кадр</small> maximumFractionDigits: 1,
})} мс</strong>
<small>на scan</small>
</article> </article>
</div> </div>
</section> </section>
<details className="polygon-review-analysis"> <details className="polygon-review-analysis">
<summary>Полные метрики и деградации</summary> <summary>Acceptance и деградации</summary>
<div className="polygon-review-analysis__content"> <div className="polygon-review-analysis__content">
<section> <section>
<span className="section-eyebrow">ACCEPTANCE GATES</span> <span className="section-eyebrow">ACCEPTANCE GATES</span>
@ -629,28 +320,20 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
<strong>{profileId}</strong> <strong>{profileId}</strong>
<span>IoU {formatPercent(aggregate.micro.groundIou)}</span> <span>IoU {formatPercent(aggregate.micro.groundIou)}</span>
<span>Natural {formatPercent(aggregate.micro.naturalGroundRecall)}</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> </p>
))} ))}
</section> </section>
</div> </div>
</details> </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"> <section className="polygon-review-unavailable">
<span className="section-eyebrow">АРХИВНЫЙ ПРОГОН</span> <span className="section-eyebrow">АРХИВНЫЙ ПРОГОН</span>
<h3>В этой записи нет perception review</h3> <h3>Для этого запуска нет perception qualification</h3>
<p>Технические доказательства доступны в раскрытии ниже.</p> <p>Техническая идентичность доступна ниже.</p>
</section> </section>
)} )}
@ -659,6 +342,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
<div className="polygon-run-technical__content"> <div className="polygon-run-technical__content">
<dl> <dl>
<div><dt>Run ID</dt><dd>{run.runId}</dd></div> <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.scenarioGeneration}</dd></div>
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div> <div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
<div><dt>Mission Core</dt><dd>{run.missionCoreCommit.slice(0, 12)}</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 decodeGroundReviewFrame;
let fetchGroundQualification; let fetchGroundQualification;
let GroundQualificationContractError; let GroundQualificationContractError;
let parseGooseFrameId;
let groupGooseReviewFrames;
const aggregate = { const aggregate = {
micro: { micro: {
@ -76,7 +78,11 @@ before(async () => {
decodeGroundReviewFrame, decodeGroundReviewFrame,
fetchGroundQualification, fetchGroundQualification,
GroundQualificationContractError, GroundQualificationContractError,
parseGooseFrameId,
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts")); } = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
({
groupGooseReviewFrames,
} = await server.ssrLoadModule("/src/workspaces/GooseDatasetReview.tsx"));
}); });
after(async () => { after(async () => {
@ -139,7 +145,7 @@ test("treats missing qualification as a generic run", async () => {
test("decodes a complete ordered frame review", () => { test("decodes a complete ordered frame review", () => {
const decoded = decodeGroundReview({ const decoded = decodeGroundReview({
schema_version: "missioncore.polygon-ground-review/v1", schema_version: "missioncore.polygon-ground-review/v2",
access: "read-only", access: "read-only",
run_id: "goose-ground-abc", run_id: "goose-ground-abc",
identity_sha256: "b".repeat(64), identity_sha256: "b".repeat(64),
@ -148,7 +154,10 @@ test("decodes a complete ordered frame review", () => {
preview_points: 12_000, preview_points: 12_000,
frames: [0, 1].map((sequence) => ({ frames: [0, 1].map((sequence) => ({
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, source_point_count: 100_000,
point_count: 12_000, point_count: 12_000,
current: { current: {
@ -168,9 +177,59 @@ test("decodes a complete ordered frame review", () => {
}); });
assert.equal(decoded.frames.length, 2); assert.equal(decoded.frames.length, 2);
assert.equal(decoded.frames[1].sequence, 1); 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); 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", () => { test("decodes a point-aligned review frame with intensity", () => {
const decoded = decodeGroundReviewFrame({ const decoded = decodeGroundReviewFrame({
schema_version: "missioncore.polygon-ground-review-frame/v1", 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: The Polygon navigation is task-oriented:
- `Прогоны` owns recorded replay/simulation history and operator review of - `Прогоны` owns selectable recorded simulation/dataset-replay/shadow history,
qualification evidence; aggregate outcomes, provenance and decisions;
- `Датасеты` owns admitted replay inputs and the entry point for a future - `Датасеты` owns admitted replay inputs, their real source structure and
qualification run. 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 Dataset bytes and source artifacts remain in Data storage, but algorithm
evaluation does not live under `Данные`. Device-specific scanner diagnostics 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 Worker capability is available. The current `Прогоны` implementation is
read-only and remains fully usable while that worker is offline. 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 Target S1B proves persisted start/health/stop history. The first direct
developer-oriented view exposed lifecycle, events, provider pins and artifact developer-oriented view exposed lifecycle, events, provider pins and artifact
hashes on the main screen. Operator review rejected that composition. The hashes on the main screen. Operator review rejected that composition and then
current UI-0 makes the recorded evidence primary: 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; - `Прогоны` always shows a run selector, even when the archive currently has
- Play/pause, previous/next and arbitrary scrub; only one item. The selected run exposes its kind (`simulation`,
- direct ground-truth, Current, Patchwork++ and error-map modes; `dataset-replay` or `shadow`), input identity, aggregate result and collapsed
- current-frame metrics and a clickable all-frame improvement/regression strip; technical evidence.
- automatic best/worst ordering without preventing arbitrary manual selection; - `Датасеты → Открыть источник` shows the admitted source. GOOSE validation is
- aggregate comparison after, rather than before, visual evidence; explicitly grouped into eight independent sequences; Play is bounded to one
- acceptance/degradation and run identity/provider/artifact details collapsed sequence and described as accelerated review of sparse annotated scans.
below the operator workflow. - 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 The backend reads the configured repository from
`MISSIONCORE_POLYGON_RUNS_ROOT` using `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. dataset frames into the selected device evidence.
- **Наблюдение** opens a concrete live or recorded spatial scene. - **Наблюдение** opens a concrete live or recorded spatial scene.
- **Данные** owns source-of-record, retention, replay preparation and export. - **Данные** 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, - **Полигон → Прогоны** 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 Before real dataset bytes exist, the catalog explains the blocked storage gate
and keeps `Открыть` / `Создать прогон` disabled. `Открыть` becomes available and keeps `Открыть` / `Создать прогон` disabled. `Открыть` becomes available
@ -341,9 +344,37 @@ qualification report:
- deterministic `12,000`-point frame previews with remission, public - deterministic `12,000`-point frame previews with remission, public
point-aligned ground truth, Current and Patchwork++ masks; point-aligned ground truth, Current and Patchwork++ masks;
- compact size `67 MB`, versus multiple gigabytes for equivalent JSON; - compact size `67 MB`, versus multiple gigabytes for equivalent JSON;
- `Play`, frame scrubber, a clickable all-frame delta strip, best/worst - direct modes for geometry, ground truth, both providers and both error maps.
ordering and 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 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 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_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview"
GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1" GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1"
GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/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" GROUND_REVIEW_FRAME_SCHEMA: Final = "missioncore.polygon-ground-review-frame/v1"
GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1" GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1"
GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/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_FRAMES: Final = 2_000
MAX_REVIEW_POINTS: Final = 20_000 MAX_REVIEW_POINTS: Final = 20_000
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$") 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, ...]] = ( READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"Архивный UI-0 публикует только квалификационные доказательства; " "Архивный UI-0 публикует только квалификационные доказательства; "
"содержимое команд и mutation-маршруты в архиве отсутствуют.", "содержимое команд и mutation-маршруты в архиве отсутствуют.",
@ -288,6 +293,7 @@ def build_polygon_router(
"preview_points": manifest["preview_points"], "preview_points": manifest["preview_points"],
"frames": [ "frames": [
{ {
**{
key: frame[key] key: frame[key]
for key in ( for key in (
"sequence", "sequence",
@ -298,6 +304,8 @@ def build_polygon_router(
"patchworkpp", "patchworkpp",
"ground_iou_delta", "ground_iou_delta",
) )
},
**_goose_frame_identity(frame["frame_id"]),
} }
for frame in manifest["frames"] for frame in manifest["frames"]
], ],
@ -717,6 +725,21 @@ def _read_ground_review_manifest(
return pack_root, 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: def _valid_review_metrics(value: object) -> bool:
if not isinstance(value, dict): if not isinstance(value, dict):
return False return False

View File

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