diff --git a/apps/control-station/src/core/polygon/groundQualification.ts b/apps/control-station/src/core/polygon/groundQualification.ts index b61feeb..9a65f94 100644 --- a/apps/control-station/src/core/polygon/groundQualification.ts +++ b/apps/control-station/src/core/polygon/groundQualification.ts @@ -63,6 +63,36 @@ export interface GroundFailurePreview { patchworkDisagreement: number[]; } +export interface GroundReviewFrameMetrics { + groundIou: number; + naturalGroundRecall: number; + obstacleNonGroundRecall: number; + latencyMs: number; +} + +export interface GroundReviewFrameSummary { + sequence: number; + frameId: string; + sourcePointCount: number; + pointCount: number; + current: GroundReviewFrameMetrics; + patchwork: GroundReviewFrameMetrics; + groundIouDelta: number; +} + +export interface GroundReview { + runId: string; + identitySha256: string; + sourceId: string; + frameCount: number; + previewPoints: number; + frames: GroundReviewFrameSummary[]; +} + +export interface GroundReviewFrame extends GroundFailurePreview { + intensity0To255: number[]; +} + export class GroundQualificationContractError extends Error {} export class GroundQualificationApiError extends Error { @@ -130,6 +160,14 @@ function boolean(value: unknown, field: string): boolean { return value; } +function integer(value: unknown, field: string, minimum = 0): number { + const result = number(value, field, minimum); + if (!Number.isInteger(result)) { + throw new GroundQualificationContractError(`${field} должен быть целым числом.`); + } + return result; +} + function aggregate(value: unknown, field: string): QualificationMetricAggregate { const source = record(value, field); const micro = record(source.micro, `${field}.micro`); @@ -316,6 +354,113 @@ export function decodeGroundFailurePreview(payload: unknown): GroundFailurePrevi }; } +function reviewFrameMetrics(value: unknown, field: string): GroundReviewFrameMetrics { + const source = record(value, field); + return { + groundIou: fraction(source.ground_iou, `${field}.ground_iou`), + naturalGroundRecall: fraction( + source.natural_ground_recall, + `${field}.natural_ground_recall`, + ), + obstacleNonGroundRecall: fraction( + source.obstacle_non_ground_recall, + `${field}.obstacle_non_ground_recall`, + ), + latencyMs: number(source.latency_ms, `${field}.latency_ms`), + }; +} + +export function decodeGroundReview(payload: unknown): GroundReview { + const source = record(payload, "ground review"); + if ( + source.schema_version !== "missioncore.polygon-ground-review/v1" + || source.access !== "read-only" + ) { + throw new GroundQualificationContractError("Неизвестная схема покадрового просмотра."); + } + const frameCount = integer(source.frame_count, "frame_count", 1); + const frames = array(source.frames, "frames", 2_000).map( + (value, index): GroundReviewFrameSummary => { + const frame = record(value, `frames[${index}]`); + const sequence = integer(frame.sequence, `frames[${index}].sequence`); + if (sequence !== index) { + throw new GroundQualificationContractError("Кадры просмотра идут не по порядку."); + } + return { + sequence, + frameId: id(frame.frame_id, `frames[${index}].frame_id`), + sourcePointCount: integer( + frame.source_point_count, + `frames[${index}].source_point_count`, + 1, + ), + pointCount: integer(frame.point_count, `frames[${index}].point_count`, 1), + current: reviewFrameMetrics(frame.current, `frames[${index}].current`), + patchwork: reviewFrameMetrics( + frame.patchworkpp, + `frames[${index}].patchworkpp`, + ), + groundIouDelta: number( + frame.ground_iou_delta, + `frames[${index}].ground_iou_delta`, + -1, + ), + }; + }, + ); + if (frames.length !== frameCount) { + throw new GroundQualificationContractError("Индекс просмотра неполный."); + } + frames.forEach((frame, index) => { + if (frame.pointCount > frame.sourcePointCount) { + throw new GroundQualificationContractError( + `frames[${index}] содержит больше preview-точек, чем source-точек.`, + ); + } + }); + const identitySha256 = text(source.identity_sha256, "identity_sha256", 64); + if (!SHA256.test(identitySha256)) { + throw new GroundQualificationContractError("Review identity должен содержать SHA-256."); + } + return { + runId: id(source.run_id, "run_id"), + identitySha256, + sourceId: text(source.source_id, "source_id"), + frameCount, + previewPoints: integer(source.preview_points, "preview_points", 1), + frames, + }; +} + +export function decodeGroundReviewFrame(payload: unknown): GroundReviewFrame { + const source = record(payload, "ground review frame"); + if ( + source.schema_version !== "missioncore.polygon-ground-review-frame/v1" + || source.access !== "read-only" + ) { + throw new GroundQualificationContractError("Неизвестная схема кадра просмотра."); + } + const compatible = { + ...source, + schema_version: "missioncore.polygon-ground-failure-preview/v1", + }; + const decoded = decodeGroundFailurePreview(compatible); + const intensity0To255 = array( + source.intensity_0_255, + "intensity_0_255", + 50_000, + ).map((value, index) => integer(value, `intensity_0_255[${index}]`)); + if ( + intensity0To255.length !== decoded.pointCount + || intensity0To255.some((value) => value > 255) + ) { + throw new GroundQualificationContractError( + "intensity_0_255 не совпадает с point_count.", + ); + } + return { ...decoded, intensity0To255 }; +} + async function requestJson( url: string, signal: AbortSignal | undefined, @@ -391,3 +536,48 @@ export async function fetchGroundFailurePreview( ), ); } + +export async function fetchGroundReview( + runId: string, + { + signal, + fetcher = globalThis.fetch, + }: { signal?: AbortSignal; fetcher?: QualificationFetch } = {}, +): Promise { + if (!SAFE_ID.test(runId)) { + throw new GroundQualificationContractError("Недопустимый run_id."); + } + try { + return decodeGroundReview( + await requestJson( + `/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification/review`, + signal, + fetcher, + ), + ); + } catch (error) { + if (error instanceof GroundQualificationApiError && error.status === 404) return null; + throw error; + } +} + +export async function fetchGroundReviewFrame( + runId: string, + frameId: string, + { + signal, + fetcher = globalThis.fetch, + }: { signal?: AbortSignal; fetcher?: QualificationFetch } = {}, +): Promise { + if (!SAFE_ID.test(runId) || !SAFE_ID.test(frameId)) { + throw new GroundQualificationContractError("Недопустимый идентификатор кадра."); + } + return decodeGroundReviewFrame( + await requestJson( + `/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification/review/frames/` + + encodeURIComponent(frameId), + signal, + fetcher, + ), + ); +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index 4e67628..ab4323d 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -156,7 +156,7 @@ export const workspaces: WorkspaceDefinition[] = [ label: "Прогоны", title: "Прогоны", eyebrow: "ПОЛИГОН / ПРОГОНЫ", - description: "Live Simulation Worker, история и доказательства квалификационных прогонов.", + description: "Покадровый replay, сравнение алгоритмов и история квалификационных прогонов.", icon: "activity", kind: "polygon-run", groups: [], diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index 7a040be..74ef661 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -97,6 +97,16 @@ } @media (max-width: 1040px) { + .polygon-review-summary > div:last-child, + .polygon-review-analysis__content, + .polygon-run-technical__content { + grid-template-columns: 1fr; + } + + .polygon-review-frame-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .polygon-qualification__grid, .polygon-qualification__failures { grid-template-columns: 1fr; @@ -183,6 +193,42 @@ } @media (max-width: 760px) { + .polygon-review-lead, + .polygon-review-player__header, + .polygon-review-order, + .polygon-review-summary > header { + align-items: stretch; + flex-direction: column; + } + + .polygon-review-lead > div:last-child { + justify-content: space-between; + } + + .polygon-review-mode { + justify-content: flex-start; + } + + .polygon-review-frame-metrics { + grid-template-columns: 1fr; + } + + .polygon-review-controls { + grid-template-columns: auto auto auto minmax(0, 1fr); + } + + .polygon-review-controls > span { + display: none; + } + + .polygon-review-summary article { + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .polygon-review-summary article small { + grid-column: 1 / -1; + } + .polygon-qualification__heading, .polygon-qualification__viewer > header { display: grid; diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index f4db2f2..75b8f27 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -1015,6 +1015,504 @@ font-size: 0.62rem; } +.polygon-review-lead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + padding: 0.15rem 0.1rem 0.25rem; +} + +.polygon-review-lead h2, +.polygon-review-lead p { + margin: 0; +} + +.polygon-review-lead h2 { + margin-top: 0.32rem; + color: var(--nodedc-text-primary); + font-size: 1.35rem; + letter-spacing: -0.04em; +} + +.polygon-review-lead p { + margin-top: 0.32rem; + color: var(--nodedc-text-muted); + font-size: 0.66rem; +} + +.polygon-review-lead > div:last-child { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.polygon-review-lead select { + max-width: 18rem; + border: 0; + border-radius: 999px; + outline: 0; + background: rgb(255 255 255 / 0.06); + color: var(--nodedc-text-secondary); + padding: 0.42rem 0.75rem; + font-size: 0.58rem; +} + +.polygon-review-player { + overflow: hidden; + border-radius: 1.15rem; + background: var(--station-panel); +} + +.polygon-review-player__header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1rem; +} + +.polygon-review-player__header > div:first-child { + display: grid; + min-width: 0; + gap: 0.25rem; +} + +.polygon-review-player__header strong { + overflow: hidden; + max-width: 36rem; + color: var(--nodedc-text-primary); + font-size: 0.67rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.polygon-review-mode, +.polygon-review-order > div { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.28rem; +} + +.polygon-review-mode button, +.polygon-review-order button, +.polygon-review-controls button { + border: 0; + border-radius: 999px; + outline: 0; + background: transparent; + color: var(--nodedc-text-muted); + padding: 0.42rem 0.68rem; + font: inherit; + font-size: 0.57rem; + cursor: pointer; +} + +.polygon-review-mode button:hover, +.polygon-review-mode button[data-active="true"], +.polygon-review-order button:hover, +.polygon-review-order button[data-active="true"], +.polygon-review-controls button:hover:not(:disabled) { + outline: 0; + background: rgb(255 255 255 / 0.1); + color: var(--nodedc-text-primary); +} + +.polygon-review-stage { + position: relative; + background: #06070a; +} + +.polygon-review-stage .lidar-ground-scene { + min-height: min(56rem, 62vh); + border-radius: 0; +} + +.polygon-review-stage__loading { + display: grid; + min-height: min(56rem, 62vh); + place-items: center; + color: var(--nodedc-text-muted); + font-size: 0.66rem; +} + +.polygon-review-stage__legend { + position: absolute; + z-index: 3; + top: 0.8rem; + left: 0.8rem; + display: grid; + max-width: 25rem; + gap: 0.18rem; + border-radius: 0.75rem; + background: rgb(7 8 10 / 0.76); + padding: 0.55rem 0.68rem; + backdrop-filter: blur(14px); +} + +.polygon-review-stage__legend strong { + color: var(--nodedc-text-primary); + font-size: 0.62rem; +} + +.polygon-review-stage__legend span { + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.polygon-review-frame-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.28rem; + padding: 0.38rem; +} + +.polygon-review-frame-metrics article { + display: grid; + min-width: 0; + gap: 0.24rem; + border-radius: 0.82rem; + background: rgb(255 255 255 / 0.035); + padding: 0.72rem 0.78rem; +} + +.polygon-review-frame-metrics span, +.polygon-review-frame-metrics small { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.54rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.polygon-review-frame-metrics strong { + color: var(--nodedc-text-primary); + font-size: 1.02rem; + font-weight: 680; +} + +.polygon-review-frame-metrics article[data-positive="false"] strong { + color: rgb(var(--nodedc-danger-rgb)); +} + +.polygon-review-controls { + display: grid; + grid-template-columns: auto auto auto minmax(8rem, 1fr) auto; + align-items: center; + gap: 0.38rem; + padding: 0.3rem 0.55rem 0.15rem; +} + +.polygon-review-controls button { + background: rgb(255 255 255 / 0.055); + color: var(--nodedc-text-secondary); +} + +.polygon-review-controls button:disabled { + opacity: 0.3; + cursor: default; +} + +.polygon-review-controls input { + width: 100%; + accent-color: #f0f0ec; +} + +.polygon-review-controls > span { + min-width: 4.5rem; + color: var(--nodedc-text-muted); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.55rem; + text-align: right; +} + +.polygon-review-order { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.2rem 0.55rem 0.45rem; +} + +.polygon-review-order > div:last-child button { + color: var(--nodedc-text-secondary); +} + +.polygon-review-timeline { + display: flex; + height: 2.4rem; + align-items: stretch; + gap: 1px; + overflow: hidden; + margin: 0 0.65rem; + border-radius: 0.42rem; + background: rgb(255 255 255 / 0.025); + padding: 0.32rem 0; +} + +.polygon-review-timeline button { + min-width: 1px; + flex: 1 1 1px; + border: 0; + border-radius: 0; + outline: 0; + background: var(--frame-color); + padding: 0; + cursor: pointer; + opacity: 0.62; +} + +.polygon-review-timeline button:hover, +.polygon-review-timeline button[data-active="true"] { + min-width: 3px; + opacity: 1; + box-shadow: 0 0 0 1px rgb(255 255 255 / 0.86); +} + +.polygon-review-timeline-note { + margin: 0; + padding: 0.25rem 0.75rem 0.78rem; + color: var(--nodedc-text-muted); + font-size: 0.54rem; + line-height: 1.45; +} + +.polygon-review-summary, +.polygon-review-unavailable, +.polygon-review-analysis, +.polygon-run-technical { + border-radius: 1rem; + background: var(--station-panel); +} + +.polygon-review-summary { + display: grid; + gap: 0.7rem; + padding: 0.9rem 1rem; +} + +.polygon-review-summary > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.polygon-review-summary h3, +.polygon-review-summary p { + margin: 0; +} + +.polygon-review-summary h3 { + margin-top: 0.28rem; + color: var(--nodedc-text-primary); + font-size: 0.94rem; +} + +.polygon-review-summary > p { + max-width: 52rem; + color: var(--nodedc-text-muted); + font-size: 0.62rem; + line-height: 1.55; +} + +.polygon-review-summary > div:last-child { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.38rem; +} + +.polygon-review-summary article { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + align-items: center; + gap: 0.58rem; + border-radius: 0.75rem; + background: rgb(255 255 255 / 0.03); + padding: 0.65rem 0.72rem; +} + +.polygon-review-summary article span, +.polygon-review-summary article small { + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.polygon-review-summary article strong { + color: var(--nodedc-text-primary); + font-size: 0.72rem; +} + +.polygon-review-summary article i { + color: var(--nodedc-text-muted); + font-style: normal; +} + +.polygon-review-analysis, +.polygon-run-technical { + overflow: hidden; +} + +.polygon-review-analysis > summary, +.polygon-run-technical > summary { + padding: 0.8rem 0.95rem; + color: var(--nodedc-text-secondary); + font-size: 0.62rem; + cursor: pointer; + list-style: none; +} + +.polygon-review-analysis > summary::-webkit-details-marker, +.polygon-run-technical > summary::-webkit-details-marker { + display: none; +} + +.polygon-review-analysis > summary::after, +.polygon-run-technical > summary::after { + float: right; + content: "+"; + color: var(--nodedc-text-muted); +} + +.polygon-review-analysis[open] > summary::after, +.polygon-run-technical[open] > summary::after { + content: "−"; +} + +.polygon-review-analysis__content { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.7rem; + padding: 0 0.65rem 0.65rem; +} + +.polygon-review-analysis__content section { + display: grid; + align-content: start; + gap: 0.25rem; + border-radius: 0.8rem; + background: rgb(255 255 255 / 0.025); + padding: 0.75rem; +} + +.polygon-review-analysis__content p { + display: grid; + grid-template-columns: auto minmax(0, 1fr) repeat(3, auto); + align-items: center; + gap: 0.45rem; + margin: 0; + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.polygon-review-analysis__content p i { + width: 0.38rem; + height: 0.38rem; + border-radius: 50%; + background: rgb(var(--nodedc-danger-rgb)); +} + +.polygon-review-analysis__content p[data-passed="true"] i { + background: rgb(var(--nodedc-success-rgb)); +} + +.polygon-review-analysis__content code { + color: var(--nodedc-text-muted); + font-size: 0.52rem; +} + +.polygon-review-unavailable { + display: grid; + min-height: 18rem; + align-content: center; + gap: 0.45rem; + padding: 1rem; +} + +.polygon-review-unavailable h3, +.polygon-review-unavailable p, +.polygon-review-unavailable small { + margin: 0; +} + +.polygon-review-unavailable h3 { + color: var(--nodedc-text-primary); + font-size: 1rem; +} + +.polygon-review-unavailable p, +.polygon-review-unavailable small { + max-width: 44rem; + color: var(--nodedc-text-muted); + font-size: 0.62rem; + line-height: 1.5; +} + +.polygon-run-technical__content { + display: grid; + grid-template-columns: 1.2fr 1fr 1fr; + gap: 0.6rem; + padding: 0 0.65rem 0.65rem; +} + +.polygon-run-technical__content > * { + min-width: 0; + border-radius: 0.75rem; + background: rgb(255 255 255 / 0.025); + padding: 0.7rem; +} + +.polygon-run-technical__content dl { + display: grid; + gap: 0.3rem; + margin: 0; +} + +.polygon-run-technical__content dl > div, +.polygon-run-technical__content section p { + display: flex; + min-width: 0; + justify-content: space-between; + gap: 0.65rem; + margin: 0; + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.polygon-run-technical__content dd { + overflow: hidden; + margin: 0; + color: var(--nodedc-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.polygon-run-technical__content h4 { + margin: 0 0 0.55rem; + color: var(--nodedc-text-primary); + font-size: 0.62rem; +} + +.polygon-run-technical__content section { + display: grid; + align-content: start; + gap: 0.4rem; +} + +.polygon-run-technical__content section p strong, +.polygon-run-technical__content section p span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.polygon-run-technical__content section p strong { + color: var(--nodedc-text-secondary); + font-weight: 600; +} + .capability-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx index 22a997d..cbb1c40 100644 --- a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx +++ b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx @@ -1,15 +1,20 @@ -import { useEffect, useMemo, useState } from "react"; import { - Button, - GlassSurface, - StatusBadge, -} from "@nodedc/ui-react"; + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, +} from "react"; +import { Button, StatusBadge } from "@nodedc/ui-react"; import { - fetchGroundFailurePreview, fetchGroundQualification, - type GroundFailurePreview, + fetchGroundReview, + fetchGroundReviewFrame, type GroundQualification, + type GroundReview, + type GroundReviewFrame, + type GroundReviewFrameSummary, } from "../core/polygon/groundQualification"; import { fetchPolygonRunCatalog, @@ -23,12 +28,13 @@ import { LidarGroundPointCloud, type LidarGroundViewMode, } from "./LidarGroundPointCloud"; -import { PolygonLivePanel } from "./PolygonLivePanel"; interface PolygonRunWorkspaceProps { route: PolygonRunRoute; } +type ReviewOrder = "sequence" | "best" | "worst"; + const stateLabels: Record = { admitted: "Допущен", starting: "Запускается", @@ -40,6 +46,21 @@ const stateLabels: Record = { aborted: "Прерван", }; +const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [ + ["intensity", "Облако"], + ["ground-truth", "Эталон"], + ["current", "Current"], + ["candidate", "Patchwork++"], + ["disagreement", "Ошибки Current"], + ["candidate-disagreement", "Ошибки Patchwork++"], +]; + +const orderLabels: ReadonlyArray<[ReviewOrder, string]> = [ + ["sequence", "По записи"], + ["best", "Лучшие"], + ["worst", "Худшие"], +]; + function stateTone( state: PolygonRunState, ): "success" | "accent" | "warning" | "danger" | "neutral" { @@ -69,7 +90,7 @@ function formatBytes(value: number): string { function errorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) return error.message; - return "Не удалось прочитать доказательства прогона."; + return "Не удалось прочитать прогон."; } function formatPercent(value: number): string { @@ -79,10 +100,65 @@ function formatPercent(value: number): string { }).format(value); } +function formatDelta(value: number): string { + const points = value * 100; + return `${points >= 0 ? "+" : ""}${points.toLocaleString("ru-RU", { + maximumFractionDigits: 1, + })} п.п.`; +} + function formatMilliseconds(value: number): string { return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`; } +function frameScene(frame: GroundReviewFrame | null) { + if (!frame) return null; + return { + pointCount: frame.pointCount, + pointsXyzM: frame.pointsXyzM, + intensity0To255: frame.intensity0To255, + masks: { + currentGround: frame.currentGround, + currentAssigned: frame.evaluated, + candidateGround: frame.patchworkGround, + candidateAssigned: frame.evaluated, + disagreement: frame.currentDisagreement, + candidateDisagreement: frame.patchworkDisagreement, + groundTruthGround: frame.groundTruthGround, + }, + }; +} + +function modeExplanation(mode: LidarGroundViewMode): string { + if (mode === "intensity") return "Нейтральная геометрия нативного LiDAR-кадра."; + if (mode === "ground-truth") return "Публичная GOOSE-разметка: земля и всё остальное."; + if (mode === "current") return "Что текущий алгоритм Mission Core считает землёй."; + if (mode === "candidate") return "Что Patchwork++ считает землёй."; + if (mode === "disagreement") return "Красным — точки, где Current расходится с эталоном."; + return "Красным — точки, где Patchwork++ расходится с эталоном."; +} + +function frameColor(delta: number): string { + if (delta < 0) { + const opacity = Math.min(0.92, 0.42 + Math.abs(delta) * 3); + return `rgb(255 104 104 / ${opacity})`; + } + const opacity = Math.min(0.9, 0.25 + delta * 2.8); + return `rgb(210 242 188 / ${opacity})`; +} + +function orderFrames( + review: GroundReview, + order: ReviewOrder, +): GroundReviewFrameSummary[] { + if (order === "sequence") return review.frames; + return [...review.frames].sort((left, right) => ( + order === "best" + ? right.groundIouDelta - left.groundIouDelta + : left.groundIouDelta - right.groundIouDelta + )); +} + export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const [catalog, setCatalog] = useState(null); const [detail, setDetail] = useState(null); @@ -91,11 +167,14 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const [error, setError] = useState(route.error); const [reloadGeneration, setReloadGeneration] = useState(0); const [qualification, setQualification] = useState(null); - const [qualificationError, setQualificationError] = useState(null); - const [failurePreview, setFailurePreview] = useState(null); - const [selectedFailureFrameId, setSelectedFailureFrameId] = useState(null); - const [failureMode, setFailureMode] = - useState("candidate-disagreement"); + const [review, setReview] = useState(null); + const [reviewError, setReviewError] = useState(null); + const [selectedFrameId, setSelectedFrameId] = useState(null); + const [reviewFrame, setReviewFrame] = useState(null); + const [reviewMode, setReviewMode] = useState("ground-truth"); + const [reviewOrder, setReviewOrder] = useState("sequence"); + const [playing, setPlaying] = useState(false); + const frameCache = useRef(new Map()); useEffect(() => { if (route.error) { @@ -135,79 +214,134 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { useEffect(() => { const runId = detail?.run.runId; - if (!runId) { - setQualification(null); - setQualificationError(null); - return; - } - const controller = new AbortController(); setQualification(null); - setQualificationError(null); - setFailurePreview(null); - setSelectedFailureFrameId(null); - void fetchGroundQualification(runId, { signal: controller.signal }) - .then((result) => { + setReview(null); + setReviewFrame(null); + setSelectedFrameId(null); + setReviewError(null); + setPlaying(false); + frameCache.current.clear(); + if (!runId) return; + const controller = new AbortController(); + void (async () => { + try { + const nextQualification = await fetchGroundQualification(runId, { + signal: controller.signal, + }); if (controller.signal.aborted) return; - setQualification(result); - setSelectedFailureFrameId(result?.worstFrames[0]?.frameId ?? null); - }) - .catch((loadError: unknown) => { - if (!controller.signal.aborted) setQualificationError(errorMessage(loadError)); - }); + setQualification(nextQualification); + if (!nextQualification) return; + const nextReview = await fetchGroundReview(runId, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + setReview(nextReview); + setSelectedFrameId(nextReview?.frames[0]?.frameId ?? null); + } catch (loadError) { + if (!controller.signal.aborted) setReviewError(errorMessage(loadError)); + } + })(); return () => controller.abort(); }, [detail?.run.runId]); useEffect(() => { - const runId = qualification?.runId; - if (!runId || !selectedFailureFrameId) { - setFailurePreview(null); + const runId = review?.runId; + if (!runId || !selectedFrameId) { + setReviewFrame(null); + return; + } + const cached = frameCache.current.get(selectedFrameId); + if (cached) { + setReviewFrame(cached); return; } const controller = new AbortController(); - setFailurePreview(null); - void fetchGroundFailurePreview(runId, selectedFailureFrameId, { + setReviewFrame(null); + void fetchGroundReviewFrame(runId, selectedFrameId, { signal: controller.signal, }) - .then((result) => { - if (!controller.signal.aborted) setFailurePreview(result); + .then((frame) => { + if (controller.signal.aborted) return; + frameCache.current.set(frame.frameId, frame); + while (frameCache.current.size > 24) { + const oldest = frameCache.current.keys().next().value as string | undefined; + if (oldest) frameCache.current.delete(oldest); + else break; + } + setReviewFrame(frame); }) .catch((loadError: unknown) => { - if (!controller.signal.aborted) setQualificationError(errorMessage(loadError)); + if (!controller.signal.aborted) setReviewError(errorMessage(loadError)); }); return () => controller.abort(); - }, [qualification?.runId, selectedFailureFrameId]); + }, [review?.runId, selectedFrameId]); - const visibleEvents = useMemo( - () => detail ? [...detail.events].reverse() : [], - [detail], + const orderedFrames = useMemo( + () => review ? orderFrames(review, reviewOrder) : [], + [review, reviewOrder], ); - const failureFrame = useMemo(() => { - if (!failurePreview) return null; - return { - pointCount: failurePreview.pointCount, - pointsXyzM: failurePreview.pointsXyzM, - intensity0To255: null, - masks: { - currentGround: failurePreview.currentGround, - currentAssigned: failurePreview.evaluated, - candidateGround: failurePreview.patchworkGround, - candidateAssigned: failurePreview.evaluated, - disagreement: failurePreview.currentDisagreement, - candidateDisagreement: failurePreview.patchworkDisagreement, - groundTruthGround: failurePreview.groundTruthGround, - }, - }; - }, [failurePreview]); + const selectedPosition = Math.max( + 0, + orderedFrames.findIndex((frame) => frame.frameId === selectedFrameId), + ); + const selectedSummary = orderedFrames[selectedPosition] ?? null; + const bestFrame = useMemo( + () => review + ? [...review.frames].sort( + (left, right) => right.groundIouDelta - left.groundIouDelta, + )[0] + : null, + [review], + ); + const worstFrame = useMemo( + () => review + ? [...review.frames].sort( + (left, right) => left.groundIouDelta - right.groundIouDelta, + )[0] + : null, + [review], + ); + + useEffect(() => { + if (!playing || !reviewFrame || !selectedFrameId || !orderedFrames.length) return; + if (reviewFrame.frameId !== selectedFrameId) return; + const timer = window.setTimeout(() => { + const nextPosition = selectedPosition + 1; + if (nextPosition >= orderedFrames.length) { + setPlaying(false); + return; + } + setSelectedFrameId(orderedFrames[nextPosition].frameId); + }, 520); + return () => window.clearTimeout(timer); + }, [ + orderedFrames, + playing, + reviewFrame, + selectedFrameId, + selectedPosition, + ]); + + const scene = useMemo(() => frameScene(reviewFrame), [reviewFrame]); + + const selectPosition = (position: number) => { + const frame = orderedFrames[Math.max(0, Math.min(position, orderedFrames.length - 1))]; + if (frame) setSelectedFrameId(frame.frameId); + }; + + const changeOrder = (order: ReviewOrder) => { + setPlaying(false); + setReviewOrder(order); + }; if (loading && !detail) { return (
- - - Только чтение -

Проверяем журнал прогона

-

Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.

-
+
+ ПОЛИГОН / ПРОГОНЫ +

Открываем запись

+

Читаем индекс кадров и готовим покадровый просмотр.

+
); } @@ -215,19 +349,18 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { if (error) { return (
- - +
Данные недоступны -

UI-0 не может открыть прогон

+

Не удалось открыть прогон

{error}

- +
); } @@ -235,12 +368,11 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { if (!detail) { return (
- - - Журнал пуст -

Квалификационных прогонов пока нет

-

Экран появится автоматически после публикации первого журнала в read-only источник.

-
+
+ ПОЛИГОН / ПРОГОНЫ +

Записей пока нет

+

Здесь появятся replay- и simulation-прогоны после публикации результатов.

+
); } @@ -248,82 +380,236 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const { run } = detail; return (
- -
+
- ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН -

{run.runId}

+ ПОЛИГОН / ЗАПИСЬ ДАТАСЕТА +

{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}

- Канонический архив Mission Core. Lifecycle live-контура отделён от истории; - команд физическим актуаторам и реального управления здесь нет. + {qualification + ? `${qualification.frameCount} кадров · Current и Patchwork++ против публичной разметки` + : "Архивный прогон без покадрового perception review."}

-
+
{stateLabels[run.state]} - read-only · {run.reproducibilityTier} + {catalog && catalog.items.length > 1 ? ( + + ) : null}
-
-
- Состояние - {stateLabels[run.state]} - {run.terminalReason ?? "терминальная причина отсутствует"} -
-
- Провайдеры - {run.providers.length} - {run.providerIds.join(" · ")} -
-
- События - {detail.eventsTotal} - {detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"} -
-
- Команды - {detail.commandCount} - содержимое не публикуется UI-0 -
-
- - {qualification ? ( -
-
-
- GOOSE VALIDATION · {qualification.frameCount} КАДРОВ -

Current против Patchwork++

-

- Публичная разметка проверяет переносимость ground pipeline. Результат остаётся - shadow-only и не даёт права на навигацию или safety. -

-
- - {qualification.decision.passed ? "Shadow candidate" : "Gate не пройден"} - -
- -
- {([ - ["Current", qualification.current], - ["Patchwork++", qualification.patchwork], - ] as const).map(([label, aggregate]) => ( -
- {label} -
-
Ground IoU
{formatPercent(aggregate.micro.groundIou)}
-
Natural ground
{formatPercent(aggregate.micro.naturalGroundRecall)}
-
Obstacle recall
{formatPercent(aggregate.micro.obstacleNonGroundRecall)}
-
Latency p95
{formatMilliseconds(aggregate.latencyMs.p95)}
-
-
- ))} -
- -
-
- ЗАРАНЕЕ ЗАФИКСИРОВАННЫЕ GATES + {qualification && review ? ( + <> +
+
+ КАДР {selectedSummary + ? selectedSummary.sequence + 1 + : "—"} / {review.frameCount} + {selectedSummary?.frameId ?? "Загружаем запись"} +
+
+ {viewModes.map(([mode, label]) => ( + + ))} +
+
+ +
+ {scene ? ( + + ) : ( +
Загружаем кадр…
+ )} +
+ {viewModes.find(([mode]) => mode === reviewMode)?.[1]} + {modeExplanation(reviewMode)} +
+
+ + {selectedSummary ? ( +
+
+ Current · Ground IoU + {formatPercent(selectedSummary.current.groundIou)} + прогноз против эталона +
+
+ Patchwork++ · Ground IoU + {formatPercent(selectedSummary.patchwork.groundIou)} + прогноз против эталона +
+
= 0 ? "true" : "false"}> + Разница на этом кадре + {formatDelta(selectedSummary.groundIouDelta)} + Patchwork++ минус Current +
+
+ Natural ground · PW++ + {formatPercent( + selectedSummary.patchwork.naturalGroundRecall, + )} + recall на естественном грунте +
+
+ ) : null} + +
+ + + + selectPosition(Number(event.target.value))} + /> + {selectedPosition + 1} / {orderedFrames.length} +
+ +
+
+ {orderLabels.map(([order, label]) => ( + + ))} +
+
+ + +
+
+ +
+ {orderedFrames.map((frame, index) => ( +
+

+ Каждая риска — доступный кадр. Светлая означает улучшение IoU, красная — + ухудшение. Можно нажать на любую и проверить результат вручную. +

+
+ +
+
+
+ ВЕСЬ VALIDATION SPLIT +

Что именно сравнивалось

+
+ + {qualification.decision.passed ? "27 / 27 проверок" : "Есть проваленные проверки"} + +
+

+ На каждом из {qualification.frameCount} нативных LiDAR-кадров оба алгоритма + независимо решали одну задачу: какие точки являются землёй. Их ответы + сравнивались с публичной point-aligned разметкой GOOSE. +

+
+
+ Ground IoU + {formatPercent(qualification.current.micro.groundIou)} + + {formatPercent(qualification.patchwork.micro.groundIou)} + {formatDelta( + qualification.patchwork.micro.groundIou + - qualification.current.micro.groundIou, + )} +
+
+ Natural ground recall + {formatPercent( + qualification.current.micro.naturalGroundRecall, + )} + + {formatPercent( + qualification.patchwork.micro.naturalGroundRecall, + )} + {formatDelta( + qualification.patchwork.micro.naturalGroundRecall + - qualification.current.micro.naturalGroundRecall, + )} +
+
+ Latency p95 + {formatMilliseconds(qualification.current.latencyMs.p95)} + + {formatMilliseconds(qualification.patchwork.latencyMs.p95)} + на кадр +
+
+
+ +
+ Полные метрики и деградации +
+
+ ACCEPTANCE GATES {qualification.checks.map((check) => (