fix(polygon): separate dataset sources from runs
This commit is contained in:
parent
2e5c4ba1c5
commit
e97573e221
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||
label: "Прогоны",
|
||||
title: "Прогоны",
|
||||
eyebrow: "ПОЛИГОН / ПРОГОНЫ",
|
||||
description: "Покадровый replay, сравнение алгоритмов и история квалификационных прогонов.",
|
||||
description: "История simulation, dataset replay и shadow-запусков с выбором конкретного прогона.",
|
||||
icon: "activity",
|
||||
kind: "polygon-run",
|
||||
groups: [],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 нормализован только для визуального просмотра 0–255</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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Reference in New Issue