feat(polygon): add full-frame operator review
This commit is contained in:
parent
cea63f9a1e
commit
b037076506
|
|
@ -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<GroundReview | null> {
|
||||
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<GroundReviewFrame> {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||
label: "Прогоны",
|
||||
title: "Прогоны",
|
||||
eyebrow: "ПОЛИГОН / ПРОГОНЫ",
|
||||
description: "Live Simulation Worker, история и доказательства квалификационных прогонов.",
|
||||
description: "Покадровый replay, сравнение алгоритмов и история квалификационных прогонов.",
|
||||
icon: "activity",
|
||||
kind: "polygon-run",
|
||||
groups: [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { Button, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchGroundFailurePreview,
|
||||
fetchGroundQualification,
|
||||
type GroundFailurePreview,
|
||||
fetchGroundReview,
|
||||
fetchGroundReviewFrame,
|
||||
type GroundQualification,
|
||||
type GroundReview,
|
||||
type GroundReviewFrame,
|
||||
type GroundReviewFrameSummary,
|
||||
} from "../core/polygon/groundQualification";
|
||||
import {
|
||||
fetchPolygonRunCatalog,
|
||||
|
|
@ -23,12 +28,13 @@ import {
|
|||
LidarGroundPointCloud,
|
||||
type LidarGroundViewMode,
|
||||
} from "./LidarGroundPointCloud";
|
||||
import { PolygonLivePanel } from "./PolygonLivePanel";
|
||||
|
||||
interface PolygonRunWorkspaceProps {
|
||||
route: PolygonRunRoute;
|
||||
}
|
||||
|
||||
type ReviewOrder = "sequence" | "best" | "worst";
|
||||
|
||||
const stateLabels: Record<PolygonRunState, string> = {
|
||||
admitted: "Допущен",
|
||||
starting: "Запускается",
|
||||
|
|
@ -40,6 +46,21 @@ const stateLabels: Record<PolygonRunState, string> = {
|
|||
aborted: "Прерван",
|
||||
};
|
||||
|
||||
const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [
|
||||
["intensity", "Облако"],
|
||||
["ground-truth", "Эталон"],
|
||||
["current", "Current"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Ошибки Current"],
|
||||
["candidate-disagreement", "Ошибки Patchwork++"],
|
||||
];
|
||||
|
||||
const orderLabels: ReadonlyArray<[ReviewOrder, string]> = [
|
||||
["sequence", "По записи"],
|
||||
["best", "Лучшие"],
|
||||
["worst", "Худшие"],
|
||||
];
|
||||
|
||||
function stateTone(
|
||||
state: PolygonRunState,
|
||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||
|
|
@ -69,7 +90,7 @@ function formatBytes(value: number): string {
|
|||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return "Не удалось прочитать доказательства прогона.";
|
||||
return "Не удалось прочитать прогон.";
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
|
|
@ -79,10 +100,65 @@ function formatPercent(value: number): string {
|
|||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDelta(value: number): string {
|
||||
const points = value * 100;
|
||||
return `${points >= 0 ? "+" : ""}${points.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} п.п.`;
|
||||
}
|
||||
|
||||
function formatMilliseconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`;
|
||||
}
|
||||
|
||||
function frameScene(frame: GroundReviewFrame | null) {
|
||||
if (!frame) return null;
|
||||
return {
|
||||
pointCount: frame.pointCount,
|
||||
pointsXyzM: frame.pointsXyzM,
|
||||
intensity0To255: frame.intensity0To255,
|
||||
masks: {
|
||||
currentGround: frame.currentGround,
|
||||
currentAssigned: frame.evaluated,
|
||||
candidateGround: frame.patchworkGround,
|
||||
candidateAssigned: frame.evaluated,
|
||||
disagreement: frame.currentDisagreement,
|
||||
candidateDisagreement: frame.patchworkDisagreement,
|
||||
groundTruthGround: frame.groundTruthGround,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function modeExplanation(mode: LidarGroundViewMode): string {
|
||||
if (mode === "intensity") return "Нейтральная геометрия нативного LiDAR-кадра.";
|
||||
if (mode === "ground-truth") return "Публичная GOOSE-разметка: земля и всё остальное.";
|
||||
if (mode === "current") return "Что текущий алгоритм Mission Core считает землёй.";
|
||||
if (mode === "candidate") return "Что Patchwork++ считает землёй.";
|
||||
if (mode === "disagreement") return "Красным — точки, где Current расходится с эталоном.";
|
||||
return "Красным — точки, где Patchwork++ расходится с эталоном.";
|
||||
}
|
||||
|
||||
function frameColor(delta: number): string {
|
||||
if (delta < 0) {
|
||||
const opacity = Math.min(0.92, 0.42 + Math.abs(delta) * 3);
|
||||
return `rgb(255 104 104 / ${opacity})`;
|
||||
}
|
||||
const opacity = Math.min(0.9, 0.25 + delta * 2.8);
|
||||
return `rgb(210 242 188 / ${opacity})`;
|
||||
}
|
||||
|
||||
function orderFrames(
|
||||
review: GroundReview,
|
||||
order: ReviewOrder,
|
||||
): GroundReviewFrameSummary[] {
|
||||
if (order === "sequence") return review.frames;
|
||||
return [...review.frames].sort((left, right) => (
|
||||
order === "best"
|
||||
? right.groundIouDelta - left.groundIouDelta
|
||||
: left.groundIouDelta - right.groundIouDelta
|
||||
));
|
||||
}
|
||||
|
||||
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
|
||||
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
|
||||
|
|
@ -91,11 +167,14 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|||
const [error, setError] = useState<string | null>(route.error);
|
||||
const [reloadGeneration, setReloadGeneration] = useState(0);
|
||||
const [qualification, setQualification] = useState<GroundQualification | null>(null);
|
||||
const [qualificationError, setQualificationError] = useState<string | null>(null);
|
||||
const [failurePreview, setFailurePreview] = useState<GroundFailurePreview | null>(null);
|
||||
const [selectedFailureFrameId, setSelectedFailureFrameId] = useState<string | null>(null);
|
||||
const [failureMode, setFailureMode] =
|
||||
useState<LidarGroundViewMode>("candidate-disagreement");
|
||||
const [review, setReview] = useState<GroundReview | null>(null);
|
||||
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(null);
|
||||
const [reviewFrame, setReviewFrame] = useState<GroundReviewFrame | null>(null);
|
||||
const [reviewMode, setReviewMode] = useState<LidarGroundViewMode>("ground-truth");
|
||||
const [reviewOrder, setReviewOrder] = useState<ReviewOrder>("sequence");
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const frameCache = useRef(new Map<string, GroundReviewFrame>());
|
||||
|
||||
useEffect(() => {
|
||||
if (route.error) {
|
||||
|
|
@ -135,79 +214,134 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|||
|
||||
useEffect(() => {
|
||||
const runId = detail?.run.runId;
|
||||
if (!runId) {
|
||||
setQualification(null);
|
||||
setQualificationError(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setQualification(null);
|
||||
setQualificationError(null);
|
||||
setFailurePreview(null);
|
||||
setSelectedFailureFrameId(null);
|
||||
void fetchGroundQualification(runId, { signal: controller.signal })
|
||||
.then((result) => {
|
||||
setReview(null);
|
||||
setReviewFrame(null);
|
||||
setSelectedFrameId(null);
|
||||
setReviewError(null);
|
||||
setPlaying(false);
|
||||
frameCache.current.clear();
|
||||
if (!runId) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const nextQualification = await fetchGroundQualification(runId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setQualification(result);
|
||||
setSelectedFailureFrameId(result?.worstFrames[0]?.frameId ?? null);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError));
|
||||
});
|
||||
setQualification(nextQualification);
|
||||
if (!nextQualification) return;
|
||||
const nextReview = await fetchGroundReview(runId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setReview(nextReview);
|
||||
setSelectedFrameId(nextReview?.frames[0]?.frameId ?? null);
|
||||
} catch (loadError) {
|
||||
if (!controller.signal.aborted) setReviewError(errorMessage(loadError));
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [detail?.run.runId]);
|
||||
|
||||
useEffect(() => {
|
||||
const runId = qualification?.runId;
|
||||
if (!runId || !selectedFailureFrameId) {
|
||||
setFailurePreview(null);
|
||||
const runId = review?.runId;
|
||||
if (!runId || !selectedFrameId) {
|
||||
setReviewFrame(null);
|
||||
return;
|
||||
}
|
||||
const cached = frameCache.current.get(selectedFrameId);
|
||||
if (cached) {
|
||||
setReviewFrame(cached);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setFailurePreview(null);
|
||||
void fetchGroundFailurePreview(runId, selectedFailureFrameId, {
|
||||
setReviewFrame(null);
|
||||
void fetchGroundReviewFrame(runId, selectedFrameId, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setFailurePreview(result);
|
||||
.then((frame) => {
|
||||
if (controller.signal.aborted) return;
|
||||
frameCache.current.set(frame.frameId, frame);
|
||||
while (frameCache.current.size > 24) {
|
||||
const oldest = frameCache.current.keys().next().value as string | undefined;
|
||||
if (oldest) frameCache.current.delete(oldest);
|
||||
else break;
|
||||
}
|
||||
setReviewFrame(frame);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError));
|
||||
if (!controller.signal.aborted) setReviewError(errorMessage(loadError));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [qualification?.runId, selectedFailureFrameId]);
|
||||
}, [review?.runId, selectedFrameId]);
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() => detail ? [...detail.events].reverse() : [],
|
||||
[detail],
|
||||
const orderedFrames = useMemo(
|
||||
() => review ? orderFrames(review, reviewOrder) : [],
|
||||
[review, reviewOrder],
|
||||
);
|
||||
const failureFrame = useMemo(() => {
|
||||
if (!failurePreview) return null;
|
||||
return {
|
||||
pointCount: failurePreview.pointCount,
|
||||
pointsXyzM: failurePreview.pointsXyzM,
|
||||
intensity0To255: null,
|
||||
masks: {
|
||||
currentGround: failurePreview.currentGround,
|
||||
currentAssigned: failurePreview.evaluated,
|
||||
candidateGround: failurePreview.patchworkGround,
|
||||
candidateAssigned: failurePreview.evaluated,
|
||||
disagreement: failurePreview.currentDisagreement,
|
||||
candidateDisagreement: failurePreview.patchworkDisagreement,
|
||||
groundTruthGround: failurePreview.groundTruthGround,
|
||||
},
|
||||
};
|
||||
}, [failurePreview]);
|
||||
const selectedPosition = Math.max(
|
||||
0,
|
||||
orderedFrames.findIndex((frame) => frame.frameId === selectedFrameId),
|
||||
);
|
||||
const selectedSummary = orderedFrames[selectedPosition] ?? null;
|
||||
const bestFrame = useMemo(
|
||||
() => review
|
||||
? [...review.frames].sort(
|
||||
(left, right) => right.groundIouDelta - left.groundIouDelta,
|
||||
)[0]
|
||||
: null,
|
||||
[review],
|
||||
);
|
||||
const worstFrame = useMemo(
|
||||
() => review
|
||||
? [...review.frames].sort(
|
||||
(left, right) => left.groundIouDelta - right.groundIouDelta,
|
||||
)[0]
|
||||
: null,
|
||||
[review],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || !reviewFrame || !selectedFrameId || !orderedFrames.length) return;
|
||||
if (reviewFrame.frameId !== selectedFrameId) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const nextPosition = selectedPosition + 1;
|
||||
if (nextPosition >= orderedFrames.length) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
setSelectedFrameId(orderedFrames[nextPosition].frameId);
|
||||
}, 520);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
orderedFrames,
|
||||
playing,
|
||||
reviewFrame,
|
||||
selectedFrameId,
|
||||
selectedPosition,
|
||||
]);
|
||||
|
||||
const scene = useMemo(() => frameScene(reviewFrame), [reviewFrame]);
|
||||
|
||||
const selectPosition = (position: number) => {
|
||||
const frame = orderedFrames[Math.max(0, Math.min(position, orderedFrames.length - 1))];
|
||||
if (frame) setSelectedFrameId(frame.frameId);
|
||||
};
|
||||
|
||||
const changeOrder = (order: ReviewOrder) => {
|
||||
setPlaying(false);
|
||||
setReviewOrder(order);
|
||||
};
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="accent">Только чтение</StatusBadge>
|
||||
<h2>Проверяем журнал прогона</h2>
|
||||
<p>Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.</p>
|
||||
</GlassSurface>
|
||||
<section className="polygon-run-message">
|
||||
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
|
||||
<h2>Открываем запись</h2>
|
||||
<p>Читаем индекс кадров и готовим покадровый просмотр.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -215,19 +349,18 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|||
if (error) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<section className="polygon-run-message">
|
||||
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
|
||||
<h2>UI-0 не может открыть прогон</h2>
|
||||
<h2>Не удалось открыть прогон</h2>
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
||||
>
|
||||
Повторить чтение
|
||||
Повторить
|
||||
</Button>
|
||||
</GlassSurface>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -235,12 +368,11 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|||
if (!detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="neutral">Журнал пуст</StatusBadge>
|
||||
<h2>Квалификационных прогонов пока нет</h2>
|
||||
<p>Экран появится автоматически после публикации первого журнала в read-only источник.</p>
|
||||
</GlassSurface>
|
||||
<section className="polygon-run-message">
|
||||
<span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
|
||||
<h2>Записей пока нет</h2>
|
||||
<p>Здесь появятся replay- и simulation-прогоны после публикации результатов.</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -248,82 +380,236 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|||
const { run } = detail;
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<section className="polygon-review-lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span>
|
||||
<h2>{run.runId}</h2>
|
||||
<span className="section-eyebrow">ПОЛИГОН / ЗАПИСЬ ДАТАСЕТА</span>
|
||||
<h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
|
||||
<p>
|
||||
Канонический архив Mission Core. Lifecycle live-контура отделён от истории;
|
||||
команд физическим актуаторам и реального управления здесь нет.
|
||||
{qualification
|
||||
? `${qualification.frameCount} кадров · Current и Patchwork++ против публичной разметки`
|
||||
: "Архивный прогон без покадрового perception review."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="polygon-run-lead-status">
|
||||
<div>
|
||||
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
|
||||
<span>read-only · {run.reproducibilityTier}</span>
|
||||
{catalog && catalog.items.length > 1 ? (
|
||||
<select
|
||||
aria-label="Выбрать прогон"
|
||||
value={run.runId}
|
||||
onChange={(event) => setSelectedRunId(event.target.value)}
|
||||
>
|
||||
{catalog.items.map((item) => (
|
||||
<option key={item.runId} value={item.runId}>{item.runId}</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="polygon-run-metrics" aria-label="Сводка прогона">
|
||||
<div>
|
||||
<span>Состояние</span>
|
||||
<strong>{stateLabels[run.state]}</strong>
|
||||
<small>{run.terminalReason ?? "терминальная причина отсутствует"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Провайдеры</span>
|
||||
<strong>{run.providers.length}</strong>
|
||||
<small>{run.providerIds.join(" · ")}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>События</span>
|
||||
<strong>{detail.eventsTotal}</strong>
|
||||
<small>{detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Команды</span>
|
||||
<strong>{detail.commandCount}</strong>
|
||||
<small>содержимое не публикуется UI-0</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{qualification ? (
|
||||
<section className="polygon-qualification" aria-label="Квалификация ground provider">
|
||||
<header className="polygon-qualification__heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">GOOSE VALIDATION · {qualification.frameCount} КАДРОВ</span>
|
||||
<h3>Current против Patchwork++</h3>
|
||||
<p>
|
||||
Публичная разметка проверяет переносимость ground pipeline. Результат остаётся
|
||||
shadow-only и не даёт права на навигацию или safety.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
|
||||
{qualification.decision.passed ? "Shadow candidate" : "Gate не пройден"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="polygon-qualification__providers">
|
||||
{([
|
||||
["Current", qualification.current],
|
||||
["Patchwork++", qualification.patchwork],
|
||||
] as const).map(([label, aggregate]) => (
|
||||
<article key={label}>
|
||||
<strong>{label}</strong>
|
||||
<dl>
|
||||
<div><dt>Ground IoU</dt><dd>{formatPercent(aggregate.micro.groundIou)}</dd></div>
|
||||
<div><dt>Natural ground</dt><dd>{formatPercent(aggregate.micro.naturalGroundRecall)}</dd></div>
|
||||
<div><dt>Obstacle recall</dt><dd>{formatPercent(aggregate.micro.obstacleNonGroundRecall)}</dd></div>
|
||||
<div><dt>Latency p95</dt><dd>{formatMilliseconds(aggregate.latencyMs.p95)}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="polygon-qualification__grid">
|
||||
<article className="polygon-qualification__checks">
|
||||
<span className="section-eyebrow">ЗАРАНЕЕ ЗАФИКСИРОВАННЫЕ GATES</span>
|
||||
{qualification && review ? (
|
||||
<>
|
||||
<section className="polygon-review-player" aria-label="Покадровый просмотр GOOSE">
|
||||
<header className="polygon-review-player__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">КАДР {selectedSummary
|
||||
? selectedSummary.sequence + 1
|
||||
: "—"} / {review.frameCount}</span>
|
||||
<strong>{selectedSummary?.frameId ?? "Загружаем запись"}</strong>
|
||||
</div>
|
||||
<div className="polygon-review-mode" aria-label="Режим визуализации">
|
||||
{viewModes.map(([mode, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
data-active={reviewMode === mode ? "true" : undefined}
|
||||
onClick={() => setReviewMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="polygon-review-stage">
|
||||
{scene ? (
|
||||
<LidarGroundPointCloud frame={scene} mode={reviewMode} />
|
||||
) : (
|
||||
<div className="polygon-review-stage__loading">Загружаем кадр…</div>
|
||||
)}
|
||||
<div className="polygon-review-stage__legend">
|
||||
<strong>{viewModes.find(([mode]) => mode === reviewMode)?.[1]}</strong>
|
||||
<span>{modeExplanation(reviewMode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSummary ? (
|
||||
<div className="polygon-review-frame-metrics">
|
||||
<article>
|
||||
<span>Current · Ground IoU</span>
|
||||
<strong>{formatPercent(selectedSummary.current.groundIou)}</strong>
|
||||
<small>прогноз против эталона</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Patchwork++ · Ground IoU</span>
|
||||
<strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong>
|
||||
<small>прогноз против эталона</small>
|
||||
</article>
|
||||
<article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}>
|
||||
<span>Разница на этом кадре</span>
|
||||
<strong>{formatDelta(selectedSummary.groundIouDelta)}</strong>
|
||||
<small>Patchwork++ минус Current</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Natural ground · PW++</span>
|
||||
<strong>{formatPercent(
|
||||
selectedSummary.patchwork.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<small>recall на естественном грунте</small>
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="polygon-review-controls">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={playing ? "Пауза" : "Воспроизвести"}
|
||||
onClick={() => setPlaying((value) => !value)}
|
||||
>
|
||||
{playing ? "Пауза" : "Play"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущий кадр"
|
||||
disabled={selectedPosition <= 0}
|
||||
onClick={() => selectPosition(selectedPosition - 1)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующий кадр"
|
||||
disabled={selectedPosition >= orderedFrames.length - 1}
|
||||
onClick={() => selectPosition(selectedPosition + 1)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, orderedFrames.length - 1)}
|
||||
value={selectedPosition}
|
||||
aria-label="Позиция в записи"
|
||||
onChange={(event) => selectPosition(Number(event.target.value))}
|
||||
/>
|
||||
<span>{selectedPosition + 1} / {orderedFrames.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="polygon-review-order">
|
||||
<div>
|
||||
{orderLabels.map(([order, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={order}
|
||||
data-active={reviewOrder === order ? "true" : undefined}
|
||||
onClick={() => changeOrder(order)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bestFrame && setSelectedFrameId(bestFrame.frameId)}
|
||||
>
|
||||
Лучший прирост
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => worstFrame && setSelectedFrameId(worstFrame.frameId)}
|
||||
>
|
||||
Худшее ухудшение
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="polygon-review-timeline"
|
||||
aria-label={`Все ${orderedFrames.length} кадров`}
|
||||
>
|
||||
{orderedFrames.map((frame, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={frame.frameId}
|
||||
aria-label={`Кадр ${frame.sequence + 1}: ${formatDelta(frame.groundIouDelta)}`}
|
||||
title={`${frame.frameId} · ${formatDelta(frame.groundIouDelta)}`}
|
||||
data-active={frame.frameId === selectedFrameId ? "true" : undefined}
|
||||
style={{ "--frame-color": frameColor(frame.groundIouDelta) } as CSSProperties}
|
||||
onClick={() => selectPosition(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="polygon-review-timeline-note">
|
||||
Каждая риска — доступный кадр. Светлая означает улучшение IoU, красная —
|
||||
ухудшение. Можно нажать на любую и проверить результат вручную.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="polygon-review-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ВЕСЬ VALIDATION SPLIT</span>
|
||||
<h3>Что именно сравнивалось</h3>
|
||||
</div>
|
||||
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
|
||||
{qualification.decision.passed ? "27 / 27 проверок" : "Есть проваленные проверки"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<p>
|
||||
На каждом из {qualification.frameCount} нативных LiDAR-кадров оба алгоритма
|
||||
независимо решали одну задачу: какие точки являются землёй. Их ответы
|
||||
сравнивались с публичной point-aligned разметкой GOOSE.
|
||||
</p>
|
||||
<div>
|
||||
<article>
|
||||
<span>Ground IoU</span>
|
||||
<strong>{formatPercent(qualification.current.micro.groundIou)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatPercent(qualification.patchwork.micro.groundIou)}</strong>
|
||||
<small>{formatDelta(
|
||||
qualification.patchwork.micro.groundIou
|
||||
- qualification.current.micro.groundIou,
|
||||
)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Natural ground recall</span>
|
||||
<strong>{formatPercent(
|
||||
qualification.current.micro.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatPercent(
|
||||
qualification.patchwork.micro.naturalGroundRecall,
|
||||
)}</strong>
|
||||
<small>{formatDelta(
|
||||
qualification.patchwork.micro.naturalGroundRecall
|
||||
- qualification.current.micro.naturalGroundRecall,
|
||||
)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Latency p95</span>
|
||||
<strong>{formatMilliseconds(qualification.current.latencyMs.p95)}</strong>
|
||||
<i>→</i>
|
||||
<strong>{formatMilliseconds(qualification.patchwork.latencyMs.p95)}</strong>
|
||||
<small>на кадр</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details className="polygon-review-analysis">
|
||||
<summary>Полные метрики и деградации</summary>
|
||||
<div className="polygon-review-analysis__content">
|
||||
<section>
|
||||
<span className="section-eyebrow">ACCEPTANCE GATES</span>
|
||||
{qualification.checks.map((check) => (
|
||||
<p key={check.checkId} data-passed={check.passed ? "true" : "false"}>
|
||||
<i aria-hidden="true" />
|
||||
|
|
@ -335,12 +621,9 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|||
</code>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="polygon-qualification__degradations">
|
||||
<span className="section-eyebrow">ДЕТЕРМИНИРОВАННЫЕ ДЕГРАДАЦИИ</span>
|
||||
<div>
|
||||
</section>
|
||||
<section>
|
||||
<span className="section-eyebrow">ДЕГРАДАЦИИ</span>
|
||||
{Object.entries(qualification.degradations).map(([profileId, aggregate]) => (
|
||||
<p key={profileId}>
|
||||
<strong>{profileId}</strong>
|
||||
|
|
@ -349,194 +632,59 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
|||
<span>p95 {formatMilliseconds(aggregate.latencyMs.p95)}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="polygon-qualification__failures">
|
||||
<article>
|
||||
<span className="section-eyebrow">ХУДШИЕ КАДРЫ</span>
|
||||
<div>
|
||||
{qualification.worstFrames.slice(0, 5).map((frame) => (
|
||||
<button
|
||||
type="button"
|
||||
key={frame.frameId}
|
||||
data-active={frame.frameId === selectedFailureFrameId ? "true" : undefined}
|
||||
onClick={() => setSelectedFailureFrameId(frame.frameId)}
|
||||
>
|
||||
<span>{frame.frameId}</span>
|
||||
<code>
|
||||
PW {formatPercent(frame.patchworkGroundIou)} ·
|
||||
Δ {formatPercent(frame.groundIouDelta)}
|
||||
</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
<article className="polygon-qualification__viewer">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РАЗБОР ОШИБКИ</span>
|
||||
<strong>{failurePreview?.frameId ?? "Загружаем кадр"}</strong>
|
||||
</div>
|
||||
<div className="lidar-ground-modes" aria-label="Режим отображения ошибки">
|
||||
{([
|
||||
["ground-truth", "Ground truth"],
|
||||
["current", "Current"],
|
||||
["candidate", "Patchwork++"],
|
||||
["disagreement", "Ошибка Current"],
|
||||
["candidate-disagreement", "Ошибка PW++"],
|
||||
] as const).map(([mode, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
data-active={failureMode === mode ? "true" : undefined}
|
||||
onClick={() => setFailureMode(mode)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
{failureFrame ? (
|
||||
<LidarGroundPointCloud frame={failureFrame} mode={failureMode} />
|
||||
) : (
|
||||
<div className="polygon-qualification__viewer-empty">
|
||||
Читаем bounded preview из sealed-артефакта прогона…
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
) : qualificationError ? (
|
||||
<div className="polygon-qualification__error">
|
||||
Квалификационный отчёт недоступен: {qualificationError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="polygon-run-layout">
|
||||
<GlassSurface className="polygon-run-catalog" padding="lg">
|
||||
<header className="polygon-run-panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ПРОГОНЫ</span>
|
||||
<h3>{catalog?.total ?? 0} в источнике</h3>
|
||||
</section>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</header>
|
||||
<div className="polygon-run-list">
|
||||
{catalog?.items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.runId}
|
||||
data-active={item.runId === run.runId ? "true" : undefined}
|
||||
onClick={() => setSelectedRunId(item.runId)}
|
||||
>
|
||||
<i data-state={item.state} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{item.runId}</strong>
|
||||
<small>{formatTimestamp(item.createdAtUtc)}</small>
|
||||
</span>
|
||||
<em>{stateLabels[item.state]}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</details>
|
||||
</>
|
||||
) : qualification ? (
|
||||
<section className="polygon-review-unavailable">
|
||||
<span className="section-eyebrow">ПОКАДРОВЫЙ ПРОСМОТР</span>
|
||||
<h3>Review-pack ещё не опубликован</h3>
|
||||
<p>
|
||||
Числовой отчёт есть, но мы не показываем его как достаточный результат:
|
||||
оператор должен иметь возможность проверить все {qualification.frameCount} кадров.
|
||||
</p>
|
||||
{reviewError ? <small>{reviewError}</small> : null}
|
||||
</section>
|
||||
) : (
|
||||
<section className="polygon-review-unavailable">
|
||||
<span className="section-eyebrow">АРХИВНЫЙ ПРОГОН</span>
|
||||
<h3>В этой записи нет perception review</h3>
|
||||
<p>Технические доказательства доступны в раскрытии ниже.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<GlassSurface className="polygon-run-identity" padding="lg">
|
||||
<span className="section-eyebrow">ИДЕНТИЧНОСТЬ И ГРАНИЦА</span>
|
||||
<details className="polygon-run-technical">
|
||||
<summary>Технические детали прогона</summary>
|
||||
<div className="polygon-run-technical__content">
|
||||
<dl>
|
||||
<div><dt>Run ID</dt><dd>{run.runId}</dd></div>
|
||||
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
|
||||
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
|
||||
<div><dt>Host profile</dt><dd>{run.hostProfileId}</dd></div>
|
||||
<div><dt>Mission Core</dt><dd><code>{run.missionCoreCommit.slice(0, 12)}</code></dd></div>
|
||||
<div><dt>Clock</dt><dd><code>{run.clockDomain}</code></dd></div>
|
||||
<div><dt>Seed</dt><dd>{run.seed}</dd></div>
|
||||
<div><dt>Mission Core</dt><dd>{run.missionCoreCommit.slice(0, 12)}</dd></div>
|
||||
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
|
||||
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
|
||||
</dl>
|
||||
<div className="polygon-run-safety-boundary">
|
||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||
<p>
|
||||
Actuator authority: {run.authority.actuatorAuthority ? "да" : "нет"} ·
|
||||
direct setpoints: {run.authority.directActuatorSetpointsAllowed ? "да" : "нет"} ·
|
||||
navigation/safety accepted: {run.authority.navigationOrSafetyAccepted ? "да" : "нет"}
|
||||
</p>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
|
||||
<section className="polygon-run-providers" aria-label="Провайдеры прогона">
|
||||
{run.providers.map((provider) => (
|
||||
<div key={provider.identifier}>
|
||||
<i aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{provider.identifier}</strong>
|
||||
<small>{provider.version}</small>
|
||||
</span>
|
||||
<code>{provider.revision.slice(0, 16)}</code>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<GlassSurface className="polygon-run-events" padding="lg">
|
||||
<header className="polygon-run-panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЖУРНАЛ СОБЫТИЙ</span>
|
||||
<h3>Последние переходы и факты</h3>
|
||||
</div>
|
||||
<span>revision {run.revision}</span>
|
||||
</header>
|
||||
<div className="polygon-run-event-list">
|
||||
{visibleEvents.map((event) => (
|
||||
<article key={event.sequence}>
|
||||
<span className="polygon-run-event-sequence">
|
||||
{String(event.sequence).padStart(3, "0")}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{event.eventType}</strong>
|
||||
<small>{formatTimestamp(event.observedAtUtc)}</small>
|
||||
<code>{JSON.stringify(event.payload)}</code>
|
||||
</div>
|
||||
<span>{event.simTimeNs === null ? "host" : `${event.simTimeNs} ns`}</span>
|
||||
</article>
|
||||
))}
|
||||
<section>
|
||||
<h4>Провайдеры</h4>
|
||||
{run.providers.map((provider) => (
|
||||
<p key={provider.identifier}>
|
||||
<strong>{provider.identifier}</strong>
|
||||
<span>{provider.version} · {provider.revision.slice(0, 16)}</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<section>
|
||||
<h4>Артефакты</h4>
|
||||
{detail.artifacts.map((artifact) => (
|
||||
<p key={artifact.artifactId}>
|
||||
<strong>{artifact.kind}</strong>
|
||||
<span>{formatBytes(artifact.byteLength)} · {artifact.sha256.slice(0, 12)}</span>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="polygon-run-evidence-grid">
|
||||
<GlassSurface className="polygon-run-artifacts" padding="lg">
|
||||
<span className="section-eyebrow">АРТЕФАКТЫ</span>
|
||||
<h3>{detail.artifacts.length || run.artifactCount} ссылок в индексе</h3>
|
||||
{detail.artifacts.length ? (
|
||||
<div>
|
||||
{detail.artifacts.map((artifact) => (
|
||||
<article key={artifact.artifactId}>
|
||||
<span>
|
||||
<strong>{artifact.kind}</strong>
|
||||
<small>{artifact.relativePath}</small>
|
||||
</span>
|
||||
<code>{artifact.sha256.slice(0, 12)} · {formatBytes(artifact.byteLength)}</code>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>В журнале прогона нет зарегистрированных artifact-index записей.</p>
|
||||
)}
|
||||
</GlassSurface>
|
||||
<GlassSurface className="polygon-run-limitations" padding="lg">
|
||||
<span className="section-eyebrow">ЧЕСТНАЯ ГРАНИЦА UI-0</span>
|
||||
<h3>Что этот результат ещё не доказывает</h3>
|
||||
<ul>
|
||||
{detail.limitations.map((limitation) => <li key={limitation}>{limitation}</li>)}
|
||||
</ul>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { createServer } from "vite";
|
|||
let server;
|
||||
let decodeGroundQualification;
|
||||
let decodeGroundFailurePreview;
|
||||
let decodeGroundReview;
|
||||
let decodeGroundReviewFrame;
|
||||
let fetchGroundQualification;
|
||||
let GroundQualificationContractError;
|
||||
|
||||
|
|
@ -70,6 +72,8 @@ before(async () => {
|
|||
({
|
||||
decodeGroundQualification,
|
||||
decodeGroundFailurePreview,
|
||||
decodeGroundReview,
|
||||
decodeGroundReviewFrame,
|
||||
fetchGroundQualification,
|
||||
GroundQualificationContractError,
|
||||
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
|
||||
|
|
@ -132,3 +136,61 @@ test("treats missing qualification as a generic run", async () => {
|
|||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("decodes a complete ordered frame review", () => {
|
||||
const decoded = decodeGroundReview({
|
||||
schema_version: "missioncore.polygon-ground-review/v1",
|
||||
access: "read-only",
|
||||
run_id: "goose-ground-abc",
|
||||
identity_sha256: "b".repeat(64),
|
||||
source_id: "goose-3d/v2025-08-22",
|
||||
frame_count: 2,
|
||||
preview_points: 12_000,
|
||||
frames: [0, 1].map((sequence) => ({
|
||||
sequence,
|
||||
frame_id: `2022-07-22_flight__0071_000${sequence}`,
|
||||
source_point_count: 100_000,
|
||||
point_count: 12_000,
|
||||
current: {
|
||||
ground_iou: 0.5,
|
||||
natural_ground_recall: 0.55,
|
||||
obstacle_non_ground_recall: 0.91,
|
||||
latency_ms: 3_000,
|
||||
},
|
||||
patchworkpp: {
|
||||
ground_iou: 0.7,
|
||||
natural_ground_recall: 0.75,
|
||||
obstacle_non_ground_recall: 0.94,
|
||||
latency_ms: 20,
|
||||
},
|
||||
ground_iou_delta: 0.2,
|
||||
})),
|
||||
});
|
||||
assert.equal(decoded.frames.length, 2);
|
||||
assert.equal(decoded.frames[1].sequence, 1);
|
||||
assert.equal(decoded.frames[0].patchwork.groundIou, 0.7);
|
||||
});
|
||||
|
||||
test("decodes a point-aligned review frame with intensity", () => {
|
||||
const decoded = decodeGroundReviewFrame({
|
||||
schema_version: "missioncore.polygon-ground-review-frame/v1",
|
||||
access: "read-only",
|
||||
run_id: "goose-ground-abc",
|
||||
source_id: "goose-3d/v2025-08-22",
|
||||
frame_id: "2022-07-22_flight__0071_0001",
|
||||
source_point_count: 2,
|
||||
point_count: 2,
|
||||
sampling: "deterministic-even-index",
|
||||
points_xyz_m: [[0, 0, 0], [1, 1, 1]],
|
||||
intensity_0_255: [12, 255],
|
||||
ground_truth_ground: [1, 0],
|
||||
evaluated: [1, 1],
|
||||
current_ground: [0, 0],
|
||||
patchwork_ground: [1, 0],
|
||||
current_disagreement: [1, 0],
|
||||
patchwork_disagreement: [0, 0],
|
||||
safety: { navigation_or_safety_accepted: false },
|
||||
});
|
||||
assert.deepEqual(decoded.intensity0To255, [12, 255]);
|
||||
assert.equal(decoded.frameId, "2022-07-22_flight__0071_0001");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from k1link.datasets.goose_benchmark import (
|
|||
benchmark_goose_patchwork_ground,
|
||||
)
|
||||
from k1link.datasets.goose_qualification import qualify_goose_ground
|
||||
from k1link.datasets.goose_review import build_goose_ground_review_pack
|
||||
|
||||
app = typer.Typer(
|
||||
add_completion=False,
|
||||
|
|
@ -118,5 +119,44 @@ def qualify_goose_ground_command(
|
|||
typer.echo(json.dumps(report, ensure_ascii=False, sort_keys=True))
|
||||
|
||||
|
||||
@app.command("build-goose-ground-review")
|
||||
def build_goose_ground_review_command(
|
||||
dataset_root: Annotated[
|
||||
Path,
|
||||
typer.Option("--dataset-root", exists=True, file_okay=False, resolve_path=True),
|
||||
],
|
||||
runs_root: Annotated[
|
||||
Path,
|
||||
typer.Option("--runs-root", exists=True, file_okay=False, resolve_path=True),
|
||||
],
|
||||
run_id: Annotated[
|
||||
str,
|
||||
typer.Option("--run-id", min=1, max=128),
|
||||
],
|
||||
workers: Annotated[
|
||||
int,
|
||||
typer.Option("--workers", min=1, max=32),
|
||||
] = 8,
|
||||
preview_points: Annotated[
|
||||
int,
|
||||
typer.Option("--preview-points", min=1, max=20_000),
|
||||
] = 12_000,
|
||||
) -> None:
|
||||
"""Publish bounded visual playback for every frame of a completed run."""
|
||||
|
||||
try:
|
||||
manifest = build_goose_ground_review_pack(
|
||||
dataset_root,
|
||||
runs_root,
|
||||
run_id=run_id,
|
||||
parallel_workers=workers,
|
||||
preview_points=preview_points,
|
||||
)
|
||||
except (GooseAdmissionError, ValueError) as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise typer.Exit(code=2) from exc
|
||||
typer.echo(json.dumps(manifest, ensure_ascii=False, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,386 @@
|
|||
"""Operator review pack for a completed GOOSE ground qualification.
|
||||
|
||||
The public dataset and the full-resolution qualification cache remain on the
|
||||
Simulation Worker D drive. This derivative stores a bounded, point-aligned
|
||||
preview for every validation frame so Mission Core can provide an honest
|
||||
playback/review surface instead of exposing only aggregate metrics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.datasets.goose_admission import (
|
||||
GOOSE_ARCHIVE_FILENAME,
|
||||
GOOSE_SOURCE_ID,
|
||||
GooseAdmissionError,
|
||||
read_goose_label_mapping,
|
||||
)
|
||||
from k1link.datasets.goose_profile import DEFAULT_GOOSE_PATCHWORK_PROFILE
|
||||
from k1link.datasets.goose_qualification import (
|
||||
REPORT_ARTIFACT_KIND,
|
||||
_challenge_categories,
|
||||
_is_worker_dataset_root,
|
||||
_read_archive_frame,
|
||||
_sha256_file,
|
||||
_validation_frame_members,
|
||||
)
|
||||
from k1link.ground_segmentation import (
|
||||
DEFAULT_GROUND_BENCHMARK_PROFILE,
|
||||
GroundSegmenter,
|
||||
LocalPercentileGroundSegmenter,
|
||||
PatchworkPPGroundSegmenter,
|
||||
)
|
||||
from k1link.simulation import QualificationRunStore, RunState
|
||||
|
||||
GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1"
|
||||
GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/v1"
|
||||
DEFAULT_REVIEW_POINTS: Final = 12_000
|
||||
MAX_REVIEW_POINTS: Final = 20_000
|
||||
_WORKER_PATCHWORK: GroundSegmenter | None = None
|
||||
|
||||
|
||||
def build_goose_ground_review_pack(
|
||||
dataset_root: Path,
|
||||
runs_root: Path,
|
||||
*,
|
||||
run_id: str,
|
||||
parallel_workers: int = 8,
|
||||
preview_points: int = DEFAULT_REVIEW_POINTS,
|
||||
patchwork_module_name: str = "pypatchworkpp",
|
||||
) -> dict[str, Any]:
|
||||
"""Build or resume a compact visual review derivative for all run frames."""
|
||||
|
||||
dataset = dataset_root.expanduser().absolute()
|
||||
runs = runs_root.expanduser().absolute()
|
||||
if not _is_worker_dataset_root(dataset):
|
||||
raise GooseAdmissionError("GOOSE review requires the canonical worker D root")
|
||||
if not 1 <= parallel_workers <= 32:
|
||||
raise GooseAdmissionError("parallel worker count is outside the admitted range")
|
||||
if not 1 <= preview_points <= MAX_REVIEW_POINTS:
|
||||
raise GooseAdmissionError("review point count is outside the admitted range")
|
||||
|
||||
store = QualificationRunStore(runs, read_only=True)
|
||||
try:
|
||||
run = store.load(run_id)
|
||||
except Exception as exc:
|
||||
raise GooseAdmissionError("qualification run is unavailable") from exc
|
||||
if run.state is not RunState.COMPLETED:
|
||||
raise GooseAdmissionError("qualification run must be completed before review export")
|
||||
report_artifacts = [
|
||||
artifact for artifact in run.artifacts if artifact.kind == REPORT_ARTIFACT_KIND
|
||||
]
|
||||
if len(report_artifacts) != 1:
|
||||
raise GooseAdmissionError("qualification run has no unique report artifact")
|
||||
report_artifact = report_artifacts[0]
|
||||
report_path = runs / run_id / report_artifact.relative_path
|
||||
if _sha256_file(report_path) != report_artifact.sha256:
|
||||
raise GooseAdmissionError("qualification report digest differs")
|
||||
try:
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise GooseAdmissionError("qualification report is invalid") from exc
|
||||
if (
|
||||
not isinstance(report, dict)
|
||||
or report.get("run_id") != run_id
|
||||
or not isinstance(report.get("frames"), list)
|
||||
):
|
||||
raise GooseAdmissionError("qualification report has no frame evidence")
|
||||
|
||||
admission_path = dataset / "state/goose-3d-v2025-08-22.json"
|
||||
try:
|
||||
admission = json.loads(admission_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise GooseAdmissionError("GOOSE admission manifest is unavailable") from exc
|
||||
archive_sha256 = str(admission.get("archive", {}).get("sha256", ""))
|
||||
if len(archive_sha256) != 64:
|
||||
raise GooseAdmissionError("GOOSE archive identity is invalid")
|
||||
install_root = dataset / "goose-3d/v2025-08-22/installs" / archive_sha256
|
||||
archive_path = dataset / "goose-3d/v2025-08-22/archives" / GOOSE_ARCHIVE_FILENAME
|
||||
label_mapping = read_goose_label_mapping(install_root / "goose_label_mapping.csv")
|
||||
members = _validation_frame_members(archive_path)
|
||||
report_frames = {
|
||||
str(frame["frame_id"]): frame
|
||||
for frame in report["frames"]
|
||||
if isinstance(frame, dict) and isinstance(frame.get("frame_id"), str)
|
||||
}
|
||||
if len(members) != len(report_frames):
|
||||
raise GooseAdmissionError("review source differs from the qualification frame set")
|
||||
|
||||
identity_document = {
|
||||
"schema_version": GOOSE_REVIEW_PACK_SCHEMA,
|
||||
"source_run_id": run_id,
|
||||
"qualification_report_sha256": report_artifact.sha256,
|
||||
"archive_sha256": archive_sha256,
|
||||
"preview_points": preview_points,
|
||||
"sampling": "deterministic-even-index",
|
||||
"coordinate_codec": "signed-centimetres-int16",
|
||||
"mask_codec": "bitset-u8-v1",
|
||||
}
|
||||
identity_sha256 = _canonical_sha256(identity_document)
|
||||
pack_root = runs.parent / "polygon-review-packs" / run_id / f"review-{identity_sha256}"
|
||||
manifest_path = pack_root / "manifest.json"
|
||||
if manifest_path.is_file():
|
||||
return _read_completed_manifest(manifest_path, run_id, identity_sha256)
|
||||
|
||||
frame_root = pack_root / "frames"
|
||||
frame_root.mkdir(parents=True, exist_ok=True)
|
||||
pending: list[tuple[int, str, str, str, Path]] = []
|
||||
completed: dict[str, dict[str, Any]] = {}
|
||||
for sequence, (frame_id, point_member, label_member) in enumerate(members):
|
||||
target = frame_root / f"{frame_id}.npz"
|
||||
metadata = _read_review_frame_metadata(target, frame_id, preview_points)
|
||||
if metadata is None:
|
||||
pending.append((sequence, frame_id, point_member, label_member, target))
|
||||
else:
|
||||
completed[frame_id] = metadata
|
||||
|
||||
if pending:
|
||||
with ProcessPoolExecutor(
|
||||
max_workers=parallel_workers,
|
||||
initializer=_initialize_patchwork_worker,
|
||||
initargs=(patchwork_module_name,),
|
||||
) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
_build_review_frame_worker,
|
||||
str(archive_path),
|
||||
label_mapping,
|
||||
frame_id,
|
||||
point_member,
|
||||
label_member,
|
||||
str(target),
|
||||
preview_points,
|
||||
): frame_id
|
||||
for _, frame_id, point_member, label_member, target in pending
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
frame_id = futures[future]
|
||||
completed[frame_id] = future.result()
|
||||
|
||||
frames: list[dict[str, Any]] = []
|
||||
for sequence, (frame_id, _, _) in enumerate(members):
|
||||
source = report_frames[frame_id]
|
||||
nominal = source.get("nominal")
|
||||
if not isinstance(nominal, dict):
|
||||
raise GooseAdmissionError("qualification frame has no nominal metrics")
|
||||
current = _review_metrics(nominal.get("current"))
|
||||
patchwork = _review_metrics(nominal.get("patchworkpp"))
|
||||
item = completed[frame_id]
|
||||
frames.append(
|
||||
{
|
||||
"sequence": sequence,
|
||||
"frame_id": frame_id,
|
||||
"source_point_count": item["source_point_count"],
|
||||
"point_count": item["point_count"],
|
||||
"relative_path": f"frames/{frame_id}.npz",
|
||||
"sha256": item["sha256"],
|
||||
"byte_length": item["byte_length"],
|
||||
"current": current,
|
||||
"patchworkpp": patchwork,
|
||||
"ground_iou_delta": (patchwork["ground_iou"] - current["ground_iou"]),
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
**identity_document,
|
||||
"identity_sha256": identity_sha256,
|
||||
"source_id": GOOSE_SOURCE_ID,
|
||||
"frame_count": len(frames),
|
||||
"frames": frames,
|
||||
"safety": {
|
||||
"visualization_only": True,
|
||||
"actuator_authority": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
_write_json_once(manifest_path, manifest)
|
||||
return _read_completed_manifest(manifest_path, run_id, identity_sha256)
|
||||
|
||||
|
||||
def _initialize_patchwork_worker(module_name: str) -> None:
|
||||
global _WORKER_PATCHWORK
|
||||
_WORKER_PATCHWORK = PatchworkPPGroundSegmenter.load(
|
||||
DEFAULT_GOOSE_PATCHWORK_PROFILE,
|
||||
module_name=module_name,
|
||||
)
|
||||
|
||||
|
||||
def _build_review_frame_worker(
|
||||
archive_path: str,
|
||||
label_mapping: dict[int, dict[str, Any]],
|
||||
frame_id: str,
|
||||
point_member: str,
|
||||
label_member: str,
|
||||
target: str,
|
||||
preview_points: int,
|
||||
) -> dict[str, Any]:
|
||||
if _WORKER_PATCHWORK is None:
|
||||
raise GooseAdmissionError("Patchwork++ review worker was not initialized")
|
||||
frame = _read_archive_frame(Path(archive_path), point_member, label_member)
|
||||
categories = _challenge_categories(frame, label_mapping)
|
||||
evaluated = categories != 0
|
||||
ground_truth = (categories == 2) | (categories == 3)
|
||||
xyzi = np.column_stack((frame.points_xyz_m, frame.remission)).astype(
|
||||
np.float32,
|
||||
copy=False,
|
||||
)
|
||||
current = LocalPercentileGroundSegmenter(DEFAULT_GROUND_BENCHMARK_PROFILE).segment(xyzi)
|
||||
patchwork = _WORKER_PATCHWORK.segment(xyzi)
|
||||
if current.ground_mask.shape != (frame.point_count,) or patchwork.ground_mask.shape != (
|
||||
frame.point_count,
|
||||
):
|
||||
raise GooseAdmissionError("review provider result is not point-aligned")
|
||||
sample_count = min(frame.point_count, preview_points)
|
||||
indices = np.linspace(0, frame.point_count - 1, sample_count, dtype=np.int64)
|
||||
points_cm = np.rint(frame.points_xyz_m[indices] * 100.0)
|
||||
if not np.all(np.isfinite(points_cm)) or np.any(np.abs(points_cm) > 32_767):
|
||||
raise GooseAdmissionError("review point escaped the signed-centimetre envelope")
|
||||
flags = (
|
||||
evaluated[indices].astype(np.uint8)
|
||||
| (ground_truth[indices].astype(np.uint8) << 1)
|
||||
| (current.ground_mask[indices].astype(np.uint8) << 2)
|
||||
| (patchwork.ground_mask[indices].astype(np.uint8) << 3)
|
||||
)
|
||||
remission = np.asarray(frame.remission[indices], dtype=np.float32)
|
||||
remission_max = float(np.max(remission)) if remission.size else 0.0
|
||||
if remission_max <= 1.0:
|
||||
remission = remission * 255.0
|
||||
remission_u8 = np.clip(np.rint(remission), 0, 255).astype(np.uint8)
|
||||
path = Path(target)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
np.savez_compressed(
|
||||
temporary,
|
||||
schema=np.asarray([GOOSE_REVIEW_FRAME_SCHEMA]),
|
||||
frame_id=np.asarray([frame_id]),
|
||||
source_point_count=np.asarray([frame.point_count], dtype=np.int32),
|
||||
xyz_cm=np.ascontiguousarray(points_cm, dtype="<i2"),
|
||||
remission_u8=remission_u8,
|
||||
flags=flags,
|
||||
)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
return {
|
||||
"source_point_count": frame.point_count,
|
||||
"point_count": sample_count,
|
||||
"sha256": _sha256_file(path),
|
||||
"byte_length": path.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def _read_review_frame_metadata(
|
||||
path: Path,
|
||||
frame_id: str,
|
||||
maximum_points: int,
|
||||
) -> dict[str, Any] | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with np.load(path, allow_pickle=False) as source:
|
||||
schema = str(source["schema"][0])
|
||||
stored_frame_id = str(source["frame_id"][0])
|
||||
source_point_count = int(source["source_point_count"][0])
|
||||
xyz_cm = source["xyz_cm"]
|
||||
remission = source["remission_u8"]
|
||||
flags = source["flags"]
|
||||
point_count = int(xyz_cm.shape[0])
|
||||
if (
|
||||
schema != GOOSE_REVIEW_FRAME_SCHEMA
|
||||
or stored_frame_id != frame_id
|
||||
or xyz_cm.dtype != np.dtype("<i2")
|
||||
or xyz_cm.shape != (point_count, 3)
|
||||
or remission.dtype != np.uint8
|
||||
or remission.shape != (point_count,)
|
||||
or flags.dtype != np.uint8
|
||||
or flags.shape != (point_count,)
|
||||
or not 1 <= point_count <= maximum_points
|
||||
or source_point_count < point_count
|
||||
):
|
||||
return None
|
||||
except (OSError, ValueError, KeyError, IndexError):
|
||||
return None
|
||||
return {
|
||||
"source_point_count": source_point_count,
|
||||
"point_count": point_count,
|
||||
"sha256": _sha256_file(path),
|
||||
"byte_length": path.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def _review_metrics(value: object) -> dict[str, float]:
|
||||
if not isinstance(value, dict) or not isinstance(value.get("metrics"), dict):
|
||||
raise GooseAdmissionError("qualification frame metrics are unavailable")
|
||||
metrics = value["metrics"]
|
||||
return {
|
||||
"ground_iou": _finite_fraction(metrics.get("ground_iou")),
|
||||
"natural_ground_recall": _finite_fraction(metrics.get("natural_ground_recall")),
|
||||
"obstacle_non_ground_recall": _finite_fraction(metrics.get("obstacle_non_ground_recall")),
|
||||
"latency_ms": _finite_nonnegative(value.get("latency_ms")),
|
||||
}
|
||||
|
||||
|
||||
def _finite_fraction(value: object) -> float:
|
||||
number = _finite_nonnegative(value)
|
||||
if number > 1:
|
||||
raise GooseAdmissionError("qualification metric is outside 0..1")
|
||||
return number
|
||||
|
||||
|
||||
def _finite_nonnegative(value: object) -> float:
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
|
||||
raise GooseAdmissionError("qualification metric is invalid")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _read_completed_manifest(
|
||||
path: Path,
|
||||
run_id: str,
|
||||
identity_sha256: str,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise GooseAdmissionError("GOOSE review manifest is invalid") from exc
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != GOOSE_REVIEW_PACK_SCHEMA
|
||||
or value.get("source_run_id") != run_id
|
||||
or value.get("identity_sha256") != identity_sha256
|
||||
or not isinstance(value.get("frames"), list)
|
||||
or value.get("frame_count") != len(value["frames"])
|
||||
):
|
||||
raise GooseAdmissionError("GOOSE review manifest contract differs")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical_sha256(value: dict[str, Any]) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _write_json_once(path: Path, value: dict[str, Any]) -> None:
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + b"\n"
|
||||
if path.exists():
|
||||
if path.is_file() and path.read_bytes() == encoded:
|
||||
return
|
||||
raise GooseAdmissionError("immutable GOOSE review manifest already exists")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
temporary.write(encoded)
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
|
|
@ -10,6 +11,7 @@ from datetime import UTC, datetime
|
|||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Header, HTTPException, Query
|
||||
from fastapi import Path as PathParameter
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
|
@ -30,6 +32,7 @@ from k1link.simulation.worker_gateway import (
|
|||
)
|
||||
|
||||
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
|
||||
POLYGON_REVIEW_ROOT_ENV: Final = "MISSIONCORE_POLYGON_REVIEW_ROOT"
|
||||
POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET"
|
||||
POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
|
||||
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
|
||||
|
|
@ -41,7 +44,15 @@ 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_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"
|
||||
MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2
|
||||
MAX_REVIEW_MANIFEST_BYTES: Final = 8 * 1024**2
|
||||
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}$")
|
||||
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
|
||||
"Архивный UI-0 публикует только квалификационные доказательства; "
|
||||
|
|
@ -258,6 +269,121 @@ def build_polygon_router(
|
|||
**{key: preview[key] for key in required},
|
||||
}
|
||||
|
||||
@router.get("/runs/{run_id}/qualification/review")
|
||||
def get_polygon_ground_review(
|
||||
run_id: Annotated[
|
||||
str,
|
||||
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
_, run = _load_run(root_provider, run_id)
|
||||
_, manifest = _read_ground_review_manifest(root_provider, run.run_id)
|
||||
return {
|
||||
"schema_version": GROUND_REVIEW_SCHEMA,
|
||||
"access": "read-only",
|
||||
"run_id": run.run_id,
|
||||
"identity_sha256": manifest["identity_sha256"],
|
||||
"source_id": manifest["source_id"],
|
||||
"frame_count": manifest["frame_count"],
|
||||
"preview_points": manifest["preview_points"],
|
||||
"frames": [
|
||||
{
|
||||
key: frame[key]
|
||||
for key in (
|
||||
"sequence",
|
||||
"frame_id",
|
||||
"source_point_count",
|
||||
"point_count",
|
||||
"current",
|
||||
"patchworkpp",
|
||||
"ground_iou_delta",
|
||||
)
|
||||
}
|
||||
for frame in manifest["frames"]
|
||||
],
|
||||
"safety": manifest["safety"],
|
||||
}
|
||||
|
||||
@router.get("/runs/{run_id}/qualification/review/frames/{frame_id}")
|
||||
def get_polygon_ground_review_frame(
|
||||
run_id: Annotated[
|
||||
str,
|
||||
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
||||
],
|
||||
frame_id: Annotated[
|
||||
str,
|
||||
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
_, run = _load_run(root_provider, run_id)
|
||||
pack_root, manifest = _read_ground_review_manifest(root_provider, run.run_id)
|
||||
matches = [frame for frame in manifest["frames"] if frame.get("frame_id") == frame_id]
|
||||
if len(matches) != 1:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Кадр визуального прогона не найден.",
|
||||
)
|
||||
frame = matches[0]
|
||||
path = _verified_review_frame_path(pack_root, frame)
|
||||
try:
|
||||
with np.load(path, allow_pickle=False) as source:
|
||||
schema = str(source["schema"][0])
|
||||
stored_frame_id = str(source["frame_id"][0])
|
||||
source_point_count = int(source["source_point_count"][0])
|
||||
xyz_cm = np.asarray(source["xyz_cm"])
|
||||
remission = np.asarray(source["remission_u8"])
|
||||
flags = np.asarray(source["flags"])
|
||||
except (OSError, ValueError, KeyError, IndexError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Кадр визуального прогона повреждён.",
|
||||
) from exc
|
||||
point_count = int(frame["point_count"])
|
||||
if (
|
||||
schema != GOOSE_REVIEW_FRAME_SCHEMA
|
||||
or stored_frame_id != frame_id
|
||||
or source_point_count != frame["source_point_count"]
|
||||
or xyz_cm.dtype != np.dtype("<i2")
|
||||
or xyz_cm.shape != (point_count, 3)
|
||||
or remission.dtype != np.uint8
|
||||
or remission.shape != (point_count,)
|
||||
or flags.dtype != np.uint8
|
||||
or flags.shape != (point_count,)
|
||||
or not 1 <= point_count <= MAX_REVIEW_POINTS
|
||||
or np.any(flags > 15)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Кадр визуального прогона нарушает контракт.",
|
||||
)
|
||||
evaluated = (flags & 1) != 0
|
||||
ground_truth = (flags & 2) != 0
|
||||
current = (flags & 4) != 0
|
||||
patchwork = (flags & 8) != 0
|
||||
return {
|
||||
"schema_version": GROUND_REVIEW_FRAME_SCHEMA,
|
||||
"access": "read-only",
|
||||
"run_id": run.run_id,
|
||||
"source_id": manifest["source_id"],
|
||||
"frame_id": frame_id,
|
||||
"source_point_count": source_point_count,
|
||||
"point_count": point_count,
|
||||
"sampling": "deterministic-even-index",
|
||||
"points_xyz_m": (xyz_cm.astype(np.float32) / 100.0).tolist(),
|
||||
"intensity_0_255": remission.tolist(),
|
||||
"ground_truth_ground": ground_truth.astype(np.uint8).tolist(),
|
||||
"evaluated": evaluated.astype(np.uint8).tolist(),
|
||||
"current_ground": current.astype(np.uint8).tolist(),
|
||||
"patchwork_ground": patchwork.astype(np.uint8).tolist(),
|
||||
"current_disagreement": (evaluated & (current != ground_truth))
|
||||
.astype(np.uint8)
|
||||
.tolist(),
|
||||
"patchwork_disagreement": (evaluated & (patchwork != ground_truth))
|
||||
.astype(np.uint8)
|
||||
.tolist(),
|
||||
"safety": manifest["safety"],
|
||||
}
|
||||
|
||||
@router.get("/worker")
|
||||
def get_polygon_worker() -> dict[str, Any]:
|
||||
gateway = worker_provider()
|
||||
|
|
@ -485,6 +611,161 @@ def _read_registered_json(
|
|||
return value
|
||||
|
||||
|
||||
def _read_ground_review_manifest(
|
||||
root_provider: RootProvider,
|
||||
run_id: str,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
runs_root = root_provider()
|
||||
if runs_root is None or not runs_root.is_absolute():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Источник визуальных прогонов не настроен.",
|
||||
)
|
||||
configured = os.environ.get(POLYGON_REVIEW_ROOT_ENV, "").strip()
|
||||
review_root = (
|
||||
Path(configured).expanduser() if configured else runs_root.parent / "polygon-review-packs"
|
||||
)
|
||||
if not review_root.is_absolute():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Источник визуальных прогонов настроен некорректно.",
|
||||
)
|
||||
run_review_root = review_root / run_id
|
||||
try:
|
||||
resolved_review_root = review_root.resolve()
|
||||
resolved_run_root = run_review_root.resolve(strict=True)
|
||||
resolved_run_root.relative_to(resolved_review_root)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Покадровый просмотр для прогона ещё не опубликован.",
|
||||
) from exc
|
||||
candidates = sorted(
|
||||
path
|
||||
for path in resolved_run_root.iterdir()
|
||||
if path.is_dir()
|
||||
and not path.is_symlink()
|
||||
and re.fullmatch(r"review-[a-f0-9]{64}", path.name)
|
||||
and (path / "manifest.json").is_file()
|
||||
)
|
||||
if not candidates:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Покадровый просмотр для прогона ещё не опубликован.",
|
||||
)
|
||||
pack_root = candidates[-1]
|
||||
manifest_path = pack_root / "manifest.json"
|
||||
try:
|
||||
if manifest_path.is_symlink() or manifest_path.stat().st_size > MAX_REVIEW_MANIFEST_BYTES:
|
||||
raise OSError
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Манифест визуального прогона повреждён.",
|
||||
) from exc
|
||||
frames = manifest.get("frames") if isinstance(manifest, dict) else None
|
||||
safety = manifest.get("safety") if isinstance(manifest, dict) else None
|
||||
if (
|
||||
not isinstance(manifest, dict)
|
||||
or manifest.get("schema_version") != GOOSE_REVIEW_PACK_SCHEMA
|
||||
or manifest.get("source_run_id") != run_id
|
||||
or not isinstance(manifest.get("identity_sha256"), str)
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", manifest["identity_sha256"])
|
||||
or pack_root.name != f"review-{manifest['identity_sha256']}"
|
||||
or not isinstance(manifest.get("source_id"), str)
|
||||
or not isinstance(manifest.get("preview_points"), int)
|
||||
or not isinstance(frames, list)
|
||||
or not 1 <= len(frames) <= MAX_REVIEW_FRAMES
|
||||
or manifest.get("frame_count") != len(frames)
|
||||
or not isinstance(safety, dict)
|
||||
or safety.get("visualization_only") is not True
|
||||
or safety.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Манифест визуального прогона нарушает контракт.",
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for expected_sequence, frame in enumerate(frames):
|
||||
if (
|
||||
not isinstance(frame, dict)
|
||||
or frame.get("sequence") != expected_sequence
|
||||
or not isinstance(frame.get("frame_id"), str)
|
||||
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", frame["frame_id"])
|
||||
or frame["frame_id"] in seen
|
||||
or not isinstance(frame.get("source_point_count"), int)
|
||||
or not isinstance(frame.get("point_count"), int)
|
||||
or not 1 <= frame["point_count"] <= MAX_REVIEW_POINTS
|
||||
or frame["source_point_count"] < frame["point_count"]
|
||||
or not isinstance(frame.get("relative_path"), str)
|
||||
or not isinstance(frame.get("sha256"), str)
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", frame["sha256"])
|
||||
or not isinstance(frame.get("byte_length"), int)
|
||||
or not 1 <= frame["byte_length"] <= MAX_REVIEW_FRAME_BYTES
|
||||
or not _valid_review_metrics(frame.get("current"))
|
||||
or not _valid_review_metrics(frame.get("patchworkpp"))
|
||||
or not isinstance(frame.get("ground_iou_delta"), (int, float))
|
||||
or not math.isfinite(frame["ground_iou_delta"])
|
||||
or not -1 <= frame["ground_iou_delta"] <= 1
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Индекс кадров визуального прогона нарушает контракт.",
|
||||
)
|
||||
seen.add(frame["frame_id"])
|
||||
return pack_root, manifest
|
||||
|
||||
|
||||
def _valid_review_metrics(value: object) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
for key in (
|
||||
"ground_iou",
|
||||
"natural_ground_recall",
|
||||
"obstacle_non_ground_recall",
|
||||
):
|
||||
metric = value.get(key)
|
||||
if (
|
||||
not isinstance(metric, (int, float))
|
||||
or not math.isfinite(metric)
|
||||
or not 0 <= metric <= 1
|
||||
):
|
||||
return False
|
||||
latency = value.get("latency_ms")
|
||||
return isinstance(latency, (int, float)) and math.isfinite(latency) and latency >= 0
|
||||
|
||||
|
||||
def _verified_review_frame_path(pack_root: Path, frame: dict[str, Any]) -> Path:
|
||||
relative = Path(frame["relative_path"])
|
||||
if relative.is_absolute() or relative.parts[:1] != ("frames",):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Кадр визуального прогона нарушает границу.",
|
||||
)
|
||||
path = pack_root / relative
|
||||
try:
|
||||
resolved = path.resolve(strict=True)
|
||||
resolved.relative_to(pack_root)
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not resolved.is_file()
|
||||
or resolved.stat().st_size != frame["byte_length"]
|
||||
):
|
||||
raise OSError
|
||||
except (OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Файл визуального прогона нарушает границу.",
|
||||
) from exc
|
||||
if hashlib.sha256(resolved.read_bytes()).hexdigest() != frame["sha256"]:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Кадр визуального прогона не прошёл SHA-256.",
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def _run_summary(run: QualificationRun) -> dict[str, Any]:
|
||||
return {
|
||||
"run_id": run.run_id,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from collections.abc import Callable
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.routing import APIRoute
|
||||
|
|
@ -333,6 +334,104 @@ def test_polygon_api_rejects_tampered_ground_qualification(
|
|||
assert "SHA-256" in failure.value.detail or "файла" in failure.value.detail
|
||||
|
||||
|
||||
def _review_pack(root: Path, run_id: str, frame_id: str) -> Path:
|
||||
identity = "d" * 64
|
||||
pack = root.parent / "polygon-review-packs" / run_id / f"review-{identity}"
|
||||
frames = pack / "frames"
|
||||
frames.mkdir(parents=True)
|
||||
frame_path = frames / f"{frame_id}.npz"
|
||||
np.savez_compressed(
|
||||
frame_path,
|
||||
schema=np.asarray(["missioncore.goose-ground-review-frame/v1"]),
|
||||
frame_id=np.asarray([frame_id]),
|
||||
source_point_count=np.asarray([3], dtype=np.int32),
|
||||
xyz_cm=np.asarray([[0, 0, 0], [100, -100, 50]], dtype="<i2"),
|
||||
remission_u8=np.asarray([12, 255], dtype=np.uint8),
|
||||
flags=np.asarray([15, 1], dtype=np.uint8),
|
||||
)
|
||||
metric = {
|
||||
"ground_iou": 0.5,
|
||||
"natural_ground_recall": 0.6,
|
||||
"obstacle_non_ground_recall": 0.95,
|
||||
"latency_ms": 20.0,
|
||||
}
|
||||
manifest = {
|
||||
"schema_version": "missioncore.goose-ground-review-pack/v1",
|
||||
"source_run_id": run_id,
|
||||
"identity_sha256": identity,
|
||||
"source_id": "goose-3d/v2025-08-22",
|
||||
"preview_points": 12_000,
|
||||
"frame_count": 1,
|
||||
"frames": [
|
||||
{
|
||||
"sequence": 0,
|
||||
"frame_id": frame_id,
|
||||
"source_point_count": 3,
|
||||
"point_count": 2,
|
||||
"relative_path": f"frames/{frame_id}.npz",
|
||||
"sha256": hashlib.sha256(frame_path.read_bytes()).hexdigest(),
|
||||
"byte_length": frame_path.stat().st_size,
|
||||
"current": metric,
|
||||
"patchworkpp": {**metric, "ground_iou": 0.7},
|
||||
"ground_iou_delta": 0.2,
|
||||
}
|
||||
],
|
||||
"safety": {
|
||||
"visualization_only": True,
|
||||
"actuator_authority": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
(pack / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
return frame_path
|
||||
|
||||
|
||||
def test_polygon_api_publishes_all_frame_review_without_paths(tmp_path: Path) -> None:
|
||||
root = tmp_path / "runs"
|
||||
run, frame_id = _qualification_run(root)
|
||||
_review_pack(root, run.run_id, frame_id)
|
||||
router = build_polygon_router(root_provider=lambda: root)
|
||||
|
||||
review = _endpoint(
|
||||
router,
|
||||
"/api/v1/polygon/runs/{run_id}/qualification/review",
|
||||
"GET",
|
||||
)(run_id=run.run_id)
|
||||
frame = _endpoint(
|
||||
router,
|
||||
"/api/v1/polygon/runs/{run_id}/qualification/review/frames/{frame_id}",
|
||||
"GET",
|
||||
)(run_id=run.run_id, frame_id=frame_id)
|
||||
|
||||
assert review["schema_version"] == "missioncore.polygon-ground-review/v1"
|
||||
assert review["frame_count"] == 1
|
||||
assert review["frames"][0]["ground_iou_delta"] == pytest.approx(0.2)
|
||||
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]]
|
||||
assert frame["intensity_0_255"] == [12, 255]
|
||||
assert frame["ground_truth_ground"] == [1, 0]
|
||||
assert frame["patchwork_ground"] == [1, 0]
|
||||
assert str(tmp_path) not in repr({"review": review, "frame": frame})
|
||||
|
||||
|
||||
def test_polygon_api_rejects_tampered_review_frame(tmp_path: Path) -> None:
|
||||
root = tmp_path / "runs"
|
||||
run, frame_id = _qualification_run(root)
|
||||
frame_path = _review_pack(root, run.run_id, frame_id)
|
||||
frame_path.write_bytes(b"tampered")
|
||||
router = build_polygon_router(root_provider=lambda: root)
|
||||
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
_endpoint(
|
||||
router,
|
||||
"/api/v1/polygon/runs/{run_id}/qualification/review/frames/{frame_id}",
|
||||
"GET",
|
||||
)(run_id=run.run_id, frame_id=frame_id)
|
||||
assert failure.value.status_code == 500
|
||||
assert "SHA-256" in failure.value.detail or "файл" in failure.value.detail.lower()
|
||||
|
||||
|
||||
class _FakeWorkerGateway:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
|
|
|||
Loading…
Reference in New Issue