diff --git a/README.md b/README.md index 4292333..f6cfd3d 100644 --- a/README.md +++ b/README.md @@ -147,9 +147,10 @@ surface/occupied/unknown evidence. All `526/526` available samples produced a diagnostic result; no free-space, command, navigation or safety authority is inferred. Leave-current-frame-out qualification produced `525` next-frame samples with `0.038543 m` p50 median residual and `12` strict temporal jumps; -the p95 residual tail remains too large for planner use. The selected scene and -clickable complete-recording timeline are visible in -**Парк → Диагностика LiDAR**. +the p95 residual tail remains too large for planner use. Read-only replay +triage narrows this to `37` attention frames in `21` episodes and four +high-priority frames. The selected scene, clickable complete-recording timeline +and source-frame review queue are visible in **Парк → Диагностика LiDAR**. The complete RELLIS-3D v1.1 release is now admitted there and its full `2,413`-frame validation split is available in **Полигон → Датасеты**. The diff --git a/apps/control-station/src/core/lidar/localSurface.ts b/apps/control-station/src/core/lidar/localSurface.ts index c06acd3..0fc853b 100644 --- a/apps/control-station/src/core/lidar/localSurface.ts +++ b/apps/control-station/src/core/lidar/localSurface.ts @@ -181,6 +181,70 @@ export interface LidarLocalSurfaceTimeline { authority: LidarLocalSurfaceModel["authority"]; } +export type LidarLocalSurfaceReviewReason = + | "prediction-tail" + | "prediction-inlier-drop" + | "surface-height-jump" + | "surface-slope-jump" + | "surface-roughness-jump"; + +export interface LidarLocalSurfaceReviewItem { + rank: number; + frameIndex: number; + sourceFrameIndex: number; + sessionSeconds: number; + episodeId: string; + attention: "high" | "review"; + attentionScore: number; + reasons: LidarLocalSurfaceReviewReason[]; + prediction: { + available: boolean; + residualP50M: number; + residualP95M: number; + inlierFraction: number; + }; + temporal: { + compared: boolean; + heightDeltaM: number; + slopeDeltaDeg: number; + roughnessDeltaM: number; + }; + surface: { + sensorHeightM: number; + slopeDeg: number; + roughnessM: number; + confidence: number; + }; + stepCandidatePointCount: number; +} + +export interface LidarLocalSurfaceReview { + reviewProfileId: "missioncore-local-surface-attention/v1"; + modelId: string; + sourcePackId: string; + sessionId: string; + available: boolean; + criteria: { + predictionTailResidualP95M: number; + predictionInlierFractionFloor: number; + surfaceHeightJumpM: number; + surfaceSlopeJumpDeg: number; + surfaceRoughnessJumpM: number; + highAttentionScore: number; + episodeMaxFrameGap: number; + }; + summary: { + itemCount: number; + episodeCount: number; + highAttentionCount: number; + reviewAttentionCount: number; + reasonCounts: Record; + }; + items: LidarLocalSurfaceReviewItem[]; + groundTruth: false; + authority: LidarLocalSurfaceModel["authority"]; +} + export class LidarLocalSurfaceContractError extends Error {} export class LidarLocalSurfaceApiError extends Error { @@ -200,6 +264,15 @@ const SAFE_MODEL_ID = new RegExp(`^${LOCAL_SURFACE_MODEL_PREFIX}[a-f0-9]{64}$`); const SAFE_PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/; const SAFE_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/; +const SAFE_EPISODE_ID = /^episode-[0-9]{2,4}$/; +const REVIEW_PROFILE_ID = "missioncore-local-surface-attention/v1"; +const REVIEW_REASONS = [ + "prediction-tail", + "prediction-inlier-drop", + "surface-height-jump", + "surface-slope-jump", + "surface-roughness-jump", +] as const satisfies readonly LidarLocalSurfaceReviewReason[]; function record(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -925,6 +998,356 @@ export function parseLidarLocalSurfaceTimeline( }; } +export function parseLidarLocalSurfaceReview( + value: unknown, +): LidarLocalSurfaceReview { + const source = record(value, "LiDAR local-surface review"); + if ( + source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-review/v1` + || source.review_profile_id !== REVIEW_PROFILE_ID + || source.access !== "read-only" + || source.ground_truth !== false + ) { + throw new LidarLocalSurfaceContractError( + "LiDAR local-surface review несовместим", + ); + } + const available = boolean(source.available, "available"); + const criteriaSource = record(source.criteria, "criteria"); + const criteria = { + predictionTailResidualP95M: finite( + criteriaSource.prediction_tail_residual_p95_m, + "criteria.prediction_tail_residual_p95_m", + ), + predictionInlierFractionFloor: finite( + criteriaSource.prediction_inlier_fraction_floor, + "criteria.prediction_inlier_fraction_floor", + ), + surfaceHeightJumpM: finite( + criteriaSource.surface_height_jump_m, + "criteria.surface_height_jump_m", + ), + surfaceSlopeJumpDeg: finite( + criteriaSource.surface_slope_jump_deg, + "criteria.surface_slope_jump_deg", + ), + surfaceRoughnessJumpM: finite( + criteriaSource.surface_roughness_jump_m, + "criteria.surface_roughness_jump_m", + ), + highAttentionScore: finite( + criteriaSource.high_attention_score, + "criteria.high_attention_score", + ), + episodeMaxFrameGap: integer( + criteriaSource.episode_max_frame_gap, + "criteria.episode_max_frame_gap", + ), + }; + if ( + criteria.predictionTailResidualP95M <= 0 + || criteria.predictionInlierFractionFloor <= 0 + || criteria.predictionInlierFractionFloor >= 1 + || criteria.surfaceHeightJumpM <= 0 + || criteria.surfaceSlopeJumpDeg <= 0 + || criteria.surfaceRoughnessJumpM <= 0 + || criteria.highAttentionScore <= 1 + || criteria.episodeMaxFrameGap < 1 + || criteria.episodeMaxFrameGap > 100 + ) { + throw new LidarLocalSurfaceContractError( + "LiDAR local-surface review criteria несовместимы", + ); + } + const summarySource = record(source.summary, "summary"); + const reasonCountsSource = record( + summarySource.reason_counts, + "summary.reason_counts", + ); + const reasonCounts: Record = { + "prediction-tail": integer( + reasonCountsSource["prediction-tail"], + "reason_counts.prediction-tail", + ), + "prediction-inlier-drop": integer( + reasonCountsSource["prediction-inlier-drop"], + "reason_counts.prediction-inlier-drop", + ), + "surface-height-jump": integer( + reasonCountsSource["surface-height-jump"], + "reason_counts.surface-height-jump", + ), + "surface-slope-jump": integer( + reasonCountsSource["surface-slope-jump"], + "reason_counts.surface-slope-jump", + ), + "surface-roughness-jump": integer( + reasonCountsSource["surface-roughness-jump"], + "reason_counts.surface-roughness-jump", + ), + }; + const summary = { + itemCount: integer(summarySource.item_count, "summary.item_count"), + episodeCount: integer(summarySource.episode_count, "summary.episode_count"), + highAttentionCount: integer( + summarySource.high_attention_count, + "summary.high_attention_count", + ), + reviewAttentionCount: integer( + summarySource.review_attention_count, + "summary.review_attention_count", + ), + reasonCounts, + }; + const rawItems = array(source.items, "items"); + if (rawItems.length > 10_000) { + throw new LidarLocalSurfaceContractError( + "LiDAR local-surface review слишком большой", + ); + } + const items = rawItems.map((value, index): LidarLocalSurfaceReviewItem => { + const item = record(value, `items[${index}]`); + const attention = item.attention; + if (attention !== "high" && attention !== "review") { + throw new LidarLocalSurfaceContractError( + `items[${index}].attention: несовместимое значение`, + ); + } + const reasons = array(item.reasons, `items[${index}].reasons`).map( + (reason, reasonIndex): LidarLocalSurfaceReviewReason => { + if ( + typeof reason !== "string" + || !REVIEW_REASONS.includes( + reason as LidarLocalSurfaceReviewReason, + ) + ) { + throw new LidarLocalSurfaceContractError( + `items[${index}].reasons[${reasonIndex}]: неизвестная причина`, + ); + } + return reason as LidarLocalSurfaceReviewReason; + }, + ); + if (!reasons.length || new Set(reasons).size !== reasons.length) { + throw new LidarLocalSurfaceContractError( + `items[${index}].reasons: несовместимый набор`, + ); + } + const predictionSource = record( + item.prediction, + `items[${index}].prediction`, + ); + const temporalSource = record( + item.temporal, + `items[${index}].temporal`, + ); + const surfaceSource = record(item.surface, `items[${index}].surface`); + const predictionAvailable = boolean( + predictionSource.available, + `items[${index}].prediction.available`, + ); + const temporalCompared = boolean( + temporalSource.compared, + `items[${index}].temporal.compared`, + ); + const predictionInlierFraction = finite( + predictionSource.inlier_fraction, + `items[${index}].prediction.inlier_fraction`, + ); + const predictionResidualP50M = finite( + predictionSource.residual_p50_m, + `items[${index}].prediction.residual_p50_m`, + ); + const predictionResidualP95M = finite( + predictionSource.residual_p95_m, + `items[${index}].prediction.residual_p95_m`, + ); + const heightDeltaM = finite( + temporalSource.height_delta_m, + `items[${index}].temporal.height_delta_m`, + ); + const slopeDeltaDeg = finite( + temporalSource.slope_delta_deg, + `items[${index}].temporal.slope_delta_deg`, + ); + const roughnessDeltaM = finite( + temporalSource.roughness_delta_m, + `items[${index}].temporal.roughness_delta_m`, + ); + const surfaceConfidence = finite( + surfaceSource.confidence, + `items[${index}].surface.confidence`, + ); + const attentionScore = finite( + item.attention_score, + `items[${index}].attention_score`, + ); + const expectedReasons: LidarLocalSurfaceReviewReason[] = []; + const expectedRatios: number[] = []; + if ( + predictionAvailable + && predictionResidualP95M >= criteria.predictionTailResidualP95M + ) { + expectedReasons.push("prediction-tail"); + expectedRatios.push( + predictionResidualP95M / criteria.predictionTailResidualP95M, + ); + } + if ( + predictionAvailable + && predictionInlierFraction < criteria.predictionInlierFractionFloor + ) { + expectedReasons.push("prediction-inlier-drop"); + expectedRatios.push( + (1 - predictionInlierFraction) + / (1 - criteria.predictionInlierFractionFloor), + ); + } + if (temporalCompared && heightDeltaM >= criteria.surfaceHeightJumpM) { + expectedReasons.push("surface-height-jump"); + expectedRatios.push(heightDeltaM / criteria.surfaceHeightJumpM); + } + if (temporalCompared && slopeDeltaDeg >= criteria.surfaceSlopeJumpDeg) { + expectedReasons.push("surface-slope-jump"); + expectedRatios.push(slopeDeltaDeg / criteria.surfaceSlopeJumpDeg); + } + if ( + temporalCompared + && roughnessDeltaM >= criteria.surfaceRoughnessJumpM + ) { + expectedReasons.push("surface-roughness-jump"); + expectedRatios.push(roughnessDeltaM / criteria.surfaceRoughnessJumpM); + } + const expectedAttentionScore = Math.max(...expectedRatios); + if ( + predictionInlierFraction < 0 + || predictionInlierFraction > 1 + || surfaceConfidence < 0 + || surfaceConfidence > 1 + || attentionScore < 1 + || (attention === "high") + !== (attentionScore >= criteria.highAttentionScore) + || ( + reasons.some((reason) => reason.startsWith("prediction-")) + && !predictionAvailable + ) + || ( + reasons.some((reason) => reason.startsWith("surface-")) + && !temporalCompared + ) + || reasons.join("|") !== expectedReasons.join("|") + || !Number.isFinite(expectedAttentionScore) + || Math.abs(attentionScore - expectedAttentionScore) > 1e-9 + ) { + throw new LidarLocalSurfaceContractError( + `items[${index}]: attention evidence несовместим`, + ); + } + return { + rank: integer(item.rank, `items[${index}].rank`), + frameIndex: integer(item.frame_index, `items[${index}].frame_index`), + sourceFrameIndex: integer( + item.source_frame_index, + `items[${index}].source_frame_index`, + ), + sessionSeconds: finite( + item.session_seconds, + `items[${index}].session_seconds`, + ), + episodeId: text( + item.episode_id, + `items[${index}].episode_id`, + SAFE_EPISODE_ID, + ), + attention, + attentionScore, + reasons, + prediction: { + available: predictionAvailable, + residualP50M: predictionResidualP50M, + residualP95M: predictionResidualP95M, + inlierFraction: predictionInlierFraction, + }, + temporal: { + compared: temporalCompared, + heightDeltaM, + slopeDeltaDeg, + roughnessDeltaM, + }, + surface: { + sensorHeightM: finite( + surfaceSource.sensor_height_m, + `items[${index}].surface.sensor_height_m`, + ), + slopeDeg: finite( + surfaceSource.slope_deg, + `items[${index}].surface.slope_deg`, + ), + roughnessM: finite( + surfaceSource.roughness_m, + `items[${index}].surface.roughness_m`, + ), + confidence: surfaceConfidence, + }, + stepCandidatePointCount: integer( + item.step_candidate_point_count, + `items[${index}].step_candidate_point_count`, + ), + }; + }); + const observedReasonCounts: Record = { + "prediction-tail": 0, + "prediction-inlier-drop": 0, + "surface-height-jump": 0, + "surface-slope-jump": 0, + "surface-roughness-jump": 0, + }; + for (const item of items) { + for (const reason of item.reasons) observedReasonCounts[reason] += 1; + } + if ( + summary.itemCount !== items.length + || summary.highAttentionCount + summary.reviewAttentionCount + !== summary.itemCount + || summary.highAttentionCount + !== items.filter((item) => item.attention === "high").length + || summary.episodeCount !== new Set(items.map((item) => item.episodeId)).size + || !REVIEW_REASONS.every( + (reason) => reasonCounts[reason] === observedReasonCounts[reason], + ) + || (!available && items.length > 0) + || items.some((item, index) => + item.rank !== index + 1 + || ( + index > 0 + && ( + item.attentionScore > items[index - 1].attentionScore + || ( + item.attentionScore === items[index - 1].attentionScore + && item.frameIndex < items[index - 1].frameIndex + ) + ) + ) + ) + ) { + throw new LidarLocalSurfaceContractError( + "LiDAR local-surface review content несовместим", + ); + } + return { + reviewProfileId: REVIEW_PROFILE_ID, + modelId: text(source.model_id, "model_id", SAFE_MODEL_ID), + sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID), + sessionId: text(source.session_id, "session_id", SAFE_ID), + available, + criteria, + summary, + items, + groundTruth: false, + authority: authority(source.authority), + }; +} + async function responseJson( response: Response, fallback: string, @@ -1015,3 +1438,29 @@ export async function fetchLidarLocalSurfaceTimeline( ), ); } + +export async function fetchLidarLocalSurfaceReview( + modelId: string, + options: { signal?: AbortSignal; fetcher?: LidarFetch } = {}, +): Promise { + if (!SAFE_MODEL_ID.test(modelId)) { + throw new LidarLocalSurfaceContractError( + "Некорректный LiDAR local-surface review", + ); + } + const fetcher = options.fetcher ?? fetch; + const response = await fetcher( + `/api/v1/lidar/local-surfaces/${modelId}/review`, + { + method: "GET", + headers: { Accept: "application/json" }, + signal: options.signal, + }, + ); + return parseLidarLocalSurfaceReview( + await responseJson( + response, + "Не удалось получить LiDAR local-surface review.", + ), + ); +} diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 245e88d..1f650e0 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -2956,13 +2956,19 @@ vector-effect: non-scaling-stroke; } +.lidar-local-surface__timeline-tail { + stroke: #f0783d; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + .lidar-local-surface__timeline-selected { stroke: rgb(255 255 255 / 0.88); stroke-width: 1; vector-effect: non-scaling-stroke; } -.lidar-local-surface__timeline footer span:nth-child(2) { +.lidar-local-surface__timeline footer span { display: flex; align-items: center; gap: 0.3rem; @@ -2975,6 +2981,133 @@ background: #f5c23d; } +.lidar-local-surface__timeline footer i[data-kind="tail"] { + background: #f0783d; +} + +.lidar-local-surface__review { + display: grid; + gap: 0.55rem; + background: rgb(255 255 255 / 0.018); + padding: 0.68rem; +} + +.lidar-local-surface__review > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} + +.lidar-local-surface__review > header > div { + display: grid; + gap: 0.12rem; +} + +.lidar-local-surface__review > header span, +.lidar-local-surface__review > header small, +.lidar-local-surface__review > footer, +.lidar-local-surface__review > p { + color: var(--nodedc-text-muted); + font-size: 0.56rem; +} + +.lidar-local-surface__review > header strong { + color: var(--nodedc-text-primary); + font-size: 0.72rem; +} + +.lidar-local-surface__review-filters { + display: flex; + flex-wrap: wrap; + gap: 0.32rem; +} + +.lidar-local-surface__review-filters button { + border: 0; + border-radius: 999px; + background: rgb(255 255 255 / 0.035); + color: var(--nodedc-text-muted); + padding: 0.34rem 0.52rem; + font-size: 0.56rem; +} + +.lidar-local-surface__review-filters button:hover, +.lidar-local-surface__review-filters button:focus-visible, +.lidar-local-surface__review-filters button[data-active="true"] { + outline: 0; + background: rgb(255 255 255 / 0.09); + color: var(--nodedc-text-primary); +} + +.lidar-local-surface__review-filters button span { + margin-left: 0.2rem; + color: var(--nodedc-text-secondary); +} + +.lidar-local-surface__review-items { + display: grid; + max-height: 17rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.34rem; + overflow-y: auto; +} + +.lidar-local-surface__review-items button { + position: relative; + display: grid; + min-width: 0; + gap: 0.16rem; + border: 0; + border-radius: 0.65rem; + background: rgb(255 255 255 / 0.025); + padding: 0.55rem 1.1rem 0.55rem 0.62rem; + text-align: left; +} + +.lidar-local-surface__review-items button::after { + position: absolute; + top: 0.62rem; + right: 0.58rem; + width: 0.35rem; + height: 0.35rem; + border-radius: 50%; + background: var(--nodedc-text-muted); + content: ""; +} + +.lidar-local-surface__review-items button[data-attention="high"]::after { + background: #f0783d; +} + +.lidar-local-surface__review-items button:hover, +.lidar-local-surface__review-items button:focus-visible, +.lidar-local-surface__review-items button[data-active="true"] { + outline: 0; + background: rgb(255 255 255 / 0.075); +} + +.lidar-local-surface__review-items span, +.lidar-local-surface__review-items small { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.54rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lidar-local-surface__review-items strong { + overflow: hidden; + color: var(--nodedc-text-secondary); + font-size: 0.61rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lidar-local-surface__review > footer { + line-height: 1.45; +} + .lidar-local-surface__stage { display: grid; overflow: hidden; diff --git a/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx b/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx index 9d92622..f24806c 100644 --- a/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx +++ b/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx @@ -3,14 +3,19 @@ import { StatusBadge } from "@nodedc/ui-react"; import { fetchLidarLocalSurfaceFrame, + fetchLidarLocalSurfaceReview, fetchLidarLocalSurfaceTimeline, fetchLidarLocalSurfaces, type LidarLocalSurfaceFrame, type LidarLocalSurfaceModel, + type LidarLocalSurfaceReview, type LidarLocalSurfaceTimeline as Timeline, } from "../core/lidar/localSurface"; import { LidarGroundPointCloud } from "./LidarGroundPointCloud"; -import { LidarLocalSurfaceTimeline } from "./LidarLocalSurfaceTimeline"; +import { + LidarLocalSurfaceReviewQueue, + LidarLocalSurfaceTimeline, +} from "./LidarLocalSurfaceTimeline"; function formatNumber(value: number | null, digits = 2): string { if (value === null) return "—"; @@ -33,6 +38,7 @@ export function LidarLocalSurfacePanel({ const [model, setModel] = useState(null); const [frame, setFrame] = useState(null); const [timeline, setTimeline] = useState(null); + const [review, setReview] = useState(null); const [selectedFrameIndex, setSelectedFrameIndex] = useState( null, ); @@ -68,6 +74,7 @@ export function LidarLocalSurfacePanel({ setModel(null); setFrame(null); setTimeline(null); + setReview(null); setError(errorMessage(loadError)); }) .finally(() => { @@ -83,18 +90,27 @@ export function LidarLocalSurfacePanel({ useEffect(() => { if (!model) { setTimeline(null); + setReview(null); return; } const controller = new AbortController(); - void fetchLidarLocalSurfaceTimeline(model.modelId, { - signal: controller.signal, - }) - .then((nextTimeline) => { - if (!controller.signal.aborted) setTimeline(nextTimeline); + void Promise.all([ + fetchLidarLocalSurfaceTimeline(model.modelId, { + signal: controller.signal, + }), + fetchLidarLocalSurfaceReview(model.modelId, { + signal: controller.signal, + }), + ]) + .then(([nextTimeline, nextReview]) => { + if (controller.signal.aborted) return; + setTimeline(nextTimeline); + setReview(nextReview); }) .catch((loadError) => { if (controller.signal.aborted) return; setTimeline(null); + setReview(null); setError(errorMessage(loadError)); }); return () => controller.abort(); @@ -143,6 +159,12 @@ export function LidarLocalSurfacePanel({ }, }; }, [frame]); + const selectedReviewItem = useMemo( + () => review?.items.find( + (item) => item.frameIndex === selectedFrameIndex, + ) ?? null, + [review, selectedFrameIndex], + ); if (!model && !loading && !error) { return null; @@ -167,7 +189,7 @@ export function LidarLocalSurfacePanel({ tone={ error ? "danger" - : frame?.temporal.jump + : selectedReviewItem ? "warning" : frame?.valid ? "success" @@ -176,8 +198,10 @@ export function LidarLocalSurfacePanel({ > {error ? "Недоступно" - : frame?.temporal.jump - ? "Temporal jump" + : selectedReviewItem + ? selectedReviewItem.attention === "high" + ? "Высокий приоритет" + : "Требует разбора" : frame?.valid ? "Кадр рассчитан" : "Диагностический режим"} @@ -226,12 +250,20 @@ export function LidarLocalSurfacePanel({ ) : null} - {timeline && selectedFrameIndex !== null ? ( - + {timeline && review && selectedFrameIndex !== null ? ( + <> + + + ) : null}
diff --git a/apps/control-station/src/workspaces/LidarLocalSurfaceTimeline.tsx b/apps/control-station/src/workspaces/LidarLocalSurfaceTimeline.tsx index 2d4e68d..4941cf8 100644 --- a/apps/control-station/src/workspaces/LidarLocalSurfaceTimeline.tsx +++ b/apps/control-station/src/workspaces/LidarLocalSurfaceTimeline.tsx @@ -1,6 +1,11 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; -import type { LidarLocalSurfaceTimeline as Timeline } from "../core/lidar/localSurface"; +import type { + LidarLocalSurfaceReview, + LidarLocalSurfaceReviewItem, + LidarLocalSurfaceReviewReason, + LidarLocalSurfaceTimeline as Timeline, +} from "../core/lidar/localSurface"; const VIEWBOX_WIDTH = 1000; const VIEWBOX_HEIGHT = 168; @@ -14,6 +19,20 @@ function formatMeters(value: number): string { }); } +function russianPlural( + value: number, + one: string, + few: string, + many: string, +): string { + const mod100 = value % 100; + const mod10 = value % 10; + if (mod100 >= 11 && mod100 <= 14) return many; + if (mod10 === 1) return one; + if (mod10 >= 2 && mod10 <= 4) return few; + return many; +} + function xAt(index: number, frameCount: number): number { if (frameCount <= 1) return 0; return (index / (frameCount - 1)) * VIEWBOX_WIDTH; @@ -21,10 +40,12 @@ function xAt(index: number, frameCount: number): number { export function LidarLocalSurfaceTimeline({ timeline, + review, selectedFrameIndex, onSelectFrame, }: { timeline: Timeline; + review: LidarLocalSurfaceReview; selectedFrameIndex: number; onSelectFrame: (frameIndex: number) => void; }) { @@ -62,6 +83,16 @@ export function LidarLocalSurfaceTimeline({ (total, value) => total + value, 0, ); + const predictionAttentionFrames = useMemo( + () => new Set( + review.items + .filter((item) => + item.reasons.some((reason) => reason.startsWith("prediction-")) + ) + .map((item) => item.frameIndex), + ), + [review.items], + ); const selectAtPointer = (clientX: number, target: SVGSVGElement) => { const bounds = target.getBoundingClientRect(); @@ -130,6 +161,16 @@ export function LidarLocalSurfaceTimeline({ className="lidar-local-surface__timeline-line" points={plot.points} /> + {[...predictionAttentionFrames].map((frameIndex) => ( + + ))} {timeline.temporalJump.map((value, index) => value === 1 ? (
начало - скачок модели + prediction tail + скачок модели конец
); } + +type ReviewFilter = "all" | "high" | "prediction" | "surface"; + +const REASON_LABELS: Record = { + "prediction-tail": "локальный хвост prediction", + "prediction-inlier-drop": "падение inliers", + "surface-height-jump": "скачок высоты", + "surface-slope-jump": "скачок уклона", + "surface-roughness-jump": "скачок шероховатости", +}; + +function hasReasonKind( + item: LidarLocalSurfaceReviewItem, + kind: "prediction" | "surface", +): boolean { + return item.reasons.some((reason) => reason.startsWith(`${kind}-`)); +} + +export function LidarLocalSurfaceReviewQueue({ + review, + selectedFrameIndex, + onSelectFrame, +}: { + review: LidarLocalSurfaceReview; + selectedFrameIndex: number; + onSelectFrame: (frameIndex: number) => void; +}) { + const [filter, setFilter] = useState("high"); + const predictionCount = review.items.filter( + (item) => hasReasonKind(item, "prediction"), + ).length; + const surfaceCount = review.items.filter( + (item) => hasReasonKind(item, "surface"), + ).length; + const visibleItems = review.items.filter((item) => { + if (filter === "high") return item.attention === "high"; + if (filter === "prediction") return hasReasonKind(item, "prediction"); + if (filter === "surface") return hasReasonKind(item, "surface"); + return true; + }); + const filters: Array<{ key: ReviewFilter; label: string; count: number }> = [ + { + key: "high", + label: "Высокий приоритет", + count: review.summary.highAttentionCount, + }, + { key: "surface", label: "Скачки поверхности", count: surfaceCount }, + { key: "prediction", label: "Хвост prediction", count: predictionCount }, + { key: "all", label: "Все кадры", count: review.summary.itemCount }, + ]; + + return ( +
+
+
+ REPLAY TRIAGE · НЕ SAFETY GATE + Кадры для разбора +
+ + {review.summary.itemCount} кадров · {review.summary.episodeCount}{" "} + {russianPlural( + review.summary.episodeCount, + "эпизод", + "эпизода", + "эпизодов", + )} + +
+
+ {filters.map((item) => ( + + ))} +
+ {visibleItems.length ? ( +
+ {visibleItems.map((item) => ( + + ))} +
+ ) : ( +

В этой группе нет кадров.

+ )} + +
+ ); +} diff --git a/apps/control-station/test/localSurface.test.mjs b/apps/control-station/test/localSurface.test.mjs index 1d292e8..9197c2e 100644 --- a/apps/control-station/test/localSurface.test.mjs +++ b/apps/control-station/test/localSurface.test.mjs @@ -7,6 +7,8 @@ let server; let parseLidarLocalSurfaceCatalog; let parseLidarLocalSurfaceFrame; let parseLidarLocalSurfaceTimeline; +let parseLidarLocalSurfaceReview; +let fetchLidarLocalSurfaceReview; let fetchLidarLocalSurfaceTimeline; let LidarLocalSurfaceContractError; @@ -237,6 +239,106 @@ function timeline(overrides = {}) { }; } +function review(overrides = {}) { + return { + schema_version: "missioncore.k1-local-surface-review/v1", + review_profile_id: "missioncore-local-surface-attention/v1", + model_id: modelId, + source_pack_id: sourcePackId, + session_id: "20260720T065719Z_viewer_live", + available: true, + criteria: { + prediction_tail_residual_p95_m: 0.45, + prediction_inlier_fraction_floor: 0.85, + surface_height_jump_m: 0.03, + surface_slope_jump_deg: 0.5, + surface_roughness_jump_m: 0.015, + high_attention_score: 2, + episode_max_frame_gap: 2, + }, + summary: { + item_count: 2, + episode_count: 2, + high_attention_count: 1, + review_attention_count: 1, + reason_counts: { + "prediction-tail": 1, + "prediction-inlier-drop": 1, + "surface-height-jump": 1, + "surface-slope-jump": 0, + "surface-roughness-jump": 0, + }, + }, + items: [ + { + rank: 1, + frame_index: 2, + source_frame_index: 1002, + session_seconds: 0.2, + episode_id: "episode-02", + attention: "high", + attention_score: 2.4, + reasons: ["prediction-tail", "prediction-inlier-drop"], + prediction: { + available: true, + residual_p50_m: 0.05, + residual_p95_m: 1.08, + inlier_fraction: 0.64, + }, + temporal: { + compared: true, + height_delta_m: 0.01, + slope_delta_deg: 0.1, + roughness_delta_m: 0.002, + }, + surface: { + sensor_height_m: 1.3, + slope_deg: 2, + roughness_m: 0.04, + confidence: 0.8, + }, + step_candidate_point_count: 13, + }, + { + rank: 2, + frame_index: 1, + source_frame_index: 1001, + session_seconds: 0.1, + episode_id: "episode-01", + attention: "review", + attention_score: 1.2, + reasons: ["surface-height-jump"], + prediction: { + available: true, + residual_p50_m: 0.04, + residual_p95_m: 0.2, + inlier_fraction: 0.95, + }, + temporal: { + compared: true, + height_delta_m: 0.036, + slope_delta_deg: 0.1, + roughness_delta_m: 0.002, + }, + surface: { + sensor_height_m: 1.34, + slope_deg: 2, + roughness_m: 0.04, + confidence: 0.8, + }, + step_candidate_point_count: 20, + }, + ], + ground_truth: false, + access: "read-only", + authority: { + commands_enabled: false, + navigation_or_safety_accepted: false, + }, + ...overrides, + }; +} + before(async () => { server = await createServer({ appType: "custom", @@ -247,6 +349,8 @@ before(async () => { parseLidarLocalSurfaceCatalog, parseLidarLocalSurfaceFrame, parseLidarLocalSurfaceTimeline, + parseLidarLocalSurfaceReview, + fetchLidarLocalSurfaceReview, fetchLidarLocalSurfaceTimeline, LidarLocalSurfaceContractError, } = await server.ssrLoadModule("/src/core/lidar/localSurface.ts")); @@ -276,6 +380,14 @@ test("decodes passive local-surface evidence", () => { assert.equal(decodedTimeline.frameCount, 4); assert.deepEqual(decodedTimeline.temporalJump, [0, 0, 1, 0]); assert.equal(decodedTimeline.predictionResidualP50M[2], 0.05); + + const decodedReview = parseLidarLocalSurfaceReview(review()); + assert.equal(decodedReview.summary.itemCount, 2); + assert.equal(decodedReview.items[0].attention, "high"); + assert.deepEqual(decodedReview.items[0].reasons, [ + "prediction-tail", + "prediction-inlier-drop", + ]); }); test("rejects inferred free space", () => { @@ -318,3 +430,30 @@ test("fetches the complete local-surface timeline read-only", async () => { }]); assert.equal(decoded.frameCount, 4); }); + +test("fetches a deterministic local-surface review queue read-only", async () => { + const requests = []; + const decoded = await fetchLidarLocalSurfaceReview(modelId, { + fetcher: async (input, init) => { + requests.push({ input: String(input), method: init?.method }); + return new Response(JSON.stringify(review()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + assert.deepEqual(requests, [{ + input: `/api/v1/lidar/local-surfaces/${modelId}/review`, + method: "GET", + }]); + assert.equal(decoded.items[0].sourceFrameIndex, 1002); +}); + +test("rejects a review queue with forged priority", () => { + const forged = review(); + forged.items[0].attention = "review"; + assert.throws( + () => parseLidarLocalSurfaceReview(forged), + LidarLocalSurfaceContractError, + ); +}); diff --git a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md index aa9c87a..d77f74f 100644 --- a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md +++ b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md @@ -2,9 +2,9 @@ Date: 2026-07-25 Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B -complete; full GOOSE and RELLIS qualification complete; L2.6b K1 replay -local-surface temporal qualification implemented; operator review and live -shadow next +complete; full GOOSE and RELLIS qualification complete; L2.6c K1 replay +local-surface temporal qualification and operator triage implemented; +residual explainability and live shadow next Scope: passively received real-time K1 point/pose evidence, immutable replay and future live shadow processing Explicitly out of scope: K1 firmware modification, a new onboard exporter, new @@ -411,6 +411,12 @@ Dataset expansion is no longer the next gate. layer with source, confidence, freshness and conflict fields. - [x] Qualify next-frame prediction and temporal stability with the evaluated frame excluded from prediction input. +- [x] Publish a deterministic, read-only operator queue for temporal + transitions and heavy prediction tails, with source-frame navigation and no + safety authority. +- [ ] Preserve the prior prediction plane and point/cell-aligned residual + evidence so a selected tail can be explained spatially instead of only by an + aggregate p95. - [ ] Complete the remaining qualification report with per-frame latency, point age, obstacle preservation and memory growth. - [ ] Replay the same profiles through a bounded latest-wins live-shadow queue; @@ -418,8 +424,8 @@ Dataset expansion is no longer the next gate. The implemented `missioncore.k1-local-surface/v1` derivative is reproducible through `experiments/perception/run_k1_local_surface.py` and is exposed -read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame -and timeline endpoints. **Парк → Диагностика LiDAR** reuses the five +read-only through `GET /api/v1/lidar/local-surfaces` plus the bound frame, +timeline and review endpoints. **Парк → Диагностика LiDAR** reuses the five RAVNOVES00 scene selectors and also exposes a clickable timeline over the complete recording. The selected source frame shows observed surface, observed occupied-above-surface, negative outlier, unclassified evidence and yellow @@ -451,6 +457,26 @@ which proves that the review layer is active but also that it is broad and still requires independent review. Free space remains unavailable; none of these metrics grants navigation, command or safety authority. +The `missioncore.k1-local-surface-review/v1` triage contract selects a frame +when its prediction p95 residual is at least `0.45 m`, prediction inlier +fraction falls below `85%`, or one of the content-bound temporal thresholds is +crossed. It groups observations separated by no more than two frames into one +episode and ranks threshold exceedance without changing the source artifact. +RAVNOVES00 produced `37` review frames in `21` episodes: `18` prediction-tail +frames, `14` inlier drops and `12` height transitions. Four frames have a +normalized attention score of at least `2.0`. + +The four highest-priority frames are concentrated in two episodes. Source +frame `1195` crosses height, slope and roughness thresholds together; the new +surface regime remains on `1196`, so the evidence is a sustained transition, +not merely a one-frame numerical spike. Source frames `1253–1254` reach +`1.047–1.083 m` p95 residual and `68.0–63.3%` inliers while the fitted global +surface remains stable. This separates a local prediction-tail problem from a +global plane-transition problem. The recording has no independent ground truth +for either episode, so the UI calls them review evidence rather than algorithm +failures. The next replay slice must retain and display the prior-plane +cell/point residuals before changing fit thresholds or entering live shadow. + Exit: one immutable K1 session yields both a persistent reconstruction and a bounded local world state without hard-coded terrain height or scanner-side changes. @@ -536,8 +562,9 @@ enough for the current decision. The first K1 local-surface replay slice now covers all available `RAVNOVES00` samples and is visible in the operator interface. Leave-current-frame-out temporal qualification now covers `525` samples: the median surface error is stable, while the p95 tail remains too -large for a free-space claim. The highest-value immediate work is review of the -`12` temporal jumps and worst prediction tails, then a bounded live-shadow -queue and separate dynamic-observation layer. Nvblox, raw-scan detectors and -alternative SLAM remain optional later gates because the current report -contract does not carry their required ray/timing semantics. +large for a free-space claim. Deterministic triage has reduced the first manual +inspection set to four high-priority frames in two episodes. The highest-value +immediate work is prior-plane residual explainability for those episodes, then +a bounded live-shadow queue and separate dynamic-observation layer. Nvblox, +raw-scan detectors and alternative SLAM remain optional later gates because the +current report contract does not carry their required ray/timing semantics. diff --git a/docs/14_LIDAR_DATASET_GATEWAY.md b/docs/14_LIDAR_DATASET_GATEWAY.md index ce63795..721a459 100644 --- a/docs/14_LIDAR_DATASET_GATEWAY.md +++ b/docs/14_LIDAR_DATASET_GATEWAY.md @@ -51,6 +51,9 @@ Implemented now: explicit pose-binding age and conservative observed occupied/unknown policy; - leave-current-frame-out next-frame qualification over `525` samples, temporal jump evidence and unverified local-discontinuity candidates; +- deterministic `missioncore.k1-local-surface-review/v1` replay triage: + `37` attention frames grouped into `21` episodes, with four + high-priority frames and direct source-frame navigation; - a provider-neutral read-only local-surface view in **Парк → Диагностика LiDAR**, synchronized to the five existing RAVNOVES00 scene selectors and a clickable complete-recording timeline; @@ -86,6 +89,8 @@ Not implemented: - no RELLIS ROS bag admission, continuous synchronized playback or production promotion; - no ray-cleared free-space or planner-authoritative rolling occupancy map. +- no point/cell-aligned prior-plane residual overlay yet; aggregate tail + evidence is not enough to identify its physical cause. ## Product surface boundary diff --git a/src/k1link/compute/__init__.py b/src/k1link/compute/__init__.py index b1291fd..d293ee0 100644 --- a/src/k1link/compute/__init__.py +++ b/src/k1link/compute/__init__.py @@ -110,6 +110,7 @@ from .lidar_local_surface import ( DEFAULT_K1_LOCAL_SURFACE_PROFILE, K1_LOCAL_SURFACE_FRAME_SCHEMA, K1_LOCAL_SURFACE_REPORT_SCHEMA, + K1_LOCAL_SURFACE_REVIEW_SCHEMA, K1_LOCAL_SURFACE_SCHEMA, K1_LOCAL_SURFACE_TIMELINE_SCHEMA, K1LocalSurfaceProfile, @@ -209,6 +210,7 @@ __all__ = [ "LIDAR_GROUND_FRAME_SCHEMA", "K1_LOCAL_SURFACE_FRAME_SCHEMA", "K1_LOCAL_SURFACE_REPORT_SCHEMA", + "K1_LOCAL_SURFACE_REVIEW_SCHEMA", "K1_LOCAL_SURFACE_SCHEMA", "K1_LOCAL_SURFACE_TIMELINE_SCHEMA", "LIDAR_FIELD_REVIEW_REPORT_SCHEMA", diff --git a/src/k1link/compute/lidar_local_surface.py b/src/k1link/compute/lidar_local_surface.py index 01b6009..d83e177 100644 --- a/src/k1link/compute/lidar_local_surface.py +++ b/src/k1link/compute/lidar_local_surface.py @@ -11,7 +11,7 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, Final +from typing import Any, Final, cast import numpy as np import numpy.typing as npt @@ -29,9 +29,14 @@ K1_LOCAL_SURFACE_SCHEMA: Final = "missioncore.k1-local-surface/v1" K1_LOCAL_SURFACE_REPORT_SCHEMA: Final = "missioncore.k1-local-surface-report/v1" K1_LOCAL_SURFACE_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v1" K1_LOCAL_SURFACE_TIMELINE_SCHEMA: Final = "missioncore.k1-local-surface-timeline/v1" +K1_LOCAL_SURFACE_REVIEW_SCHEMA: Final = "missioncore.k1-local-surface-review/v1" K1_LOCAL_SURFACE_ARRAYS_NAME: Final = "local-surface.npz" K1_LOCAL_SURFACE_REPORT_NAME: Final = "local-surface.json" K1_LOCAL_SURFACE_MANIFEST_NAME: Final = "manifest.json" +K1_LOCAL_SURFACE_REVIEW_PROFILE_ID: Final = "missioncore-local-surface-attention/v1" +K1_LOCAL_SURFACE_REVIEW_TAIL_M: Final = 0.45 +K1_LOCAL_SURFACE_REVIEW_INLIER_FLOOR: Final = 0.85 +K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE: Final = 2.0 POINT_UNCLASSIFIED: Final = 0 POINT_SURFACE: Final = 1 @@ -631,6 +636,195 @@ class K1LocalSurfaceV1: "authority": self.report["authority"], } + def review_detail(self, source: E10LidarFieldSource) -> dict[str, object]: + _validate_source_binding(self, source) + criteria = self._review_criteria() + reason_counts = { + "prediction-tail": 0, + "prediction-inlier-drop": 0, + "surface-height-jump": 0, + "surface-slope-jump": 0, + "surface-roughness-jump": 0, + } + if not self.has_temporal_qualification: + return { + "schema_version": K1_LOCAL_SURFACE_REVIEW_SCHEMA, + "review_profile_id": K1_LOCAL_SURFACE_REVIEW_PROFILE_ID, + "model_id": self.model_id, + "source_pack_id": source.pack_id, + "session_id": source.identity["session_id"], + "available": False, + "criteria": criteria, + "summary": { + "item_count": 0, + "episode_count": 0, + "high_attention_count": 0, + "review_attention_count": 0, + "reason_counts": reason_counts, + }, + "items": [], + "ground_truth": False, + "access": "read-only", + "authority": self.report["authority"], + } + + tail_threshold = float(criteria["prediction_tail_residual_p95_m"]) + inlier_floor = float(criteria["prediction_inlier_fraction_floor"]) + height_threshold = float(criteria["surface_height_jump_m"]) + slope_threshold = float(criteria["surface_slope_jump_deg"]) + roughness_threshold = float(criteria["surface_roughness_jump_m"]) + chronological: list[dict[str, object]] = [] + last_review_frame: int | None = None + episode_index = 0 + for frame_index in range(source.frame_count): + reasons: list[str] = [] + ratios: list[float] = [] + prediction_available = bool( + self.arrays["prediction_available"][frame_index] + ) + prediction_p95 = float( + self.arrays["prediction_residual_p95_m"][frame_index] + ) + prediction_inlier = float( + self.arrays["prediction_inlier_fraction"][frame_index] + ) + if prediction_available and prediction_p95 >= tail_threshold: + reasons.append("prediction-tail") + ratios.append(prediction_p95 / tail_threshold) + if prediction_available and prediction_inlier < inlier_floor: + reasons.append("prediction-inlier-drop") + ratios.append((1.0 - prediction_inlier) / (1.0 - inlier_floor)) + + temporal_compared = bool(self.arrays["temporal_compared"][frame_index]) + height_delta = float(self.arrays["height_delta_m"][frame_index]) + slope_delta = float(self.arrays["slope_delta_deg"][frame_index]) + roughness_delta = float(self.arrays["roughness_delta_m"][frame_index]) + if temporal_compared and height_delta >= height_threshold: + reasons.append("surface-height-jump") + ratios.append(height_delta / height_threshold) + if temporal_compared and slope_delta >= slope_threshold: + reasons.append("surface-slope-jump") + ratios.append(slope_delta / slope_threshold) + if temporal_compared and roughness_delta >= roughness_threshold: + reasons.append("surface-roughness-jump") + ratios.append(roughness_delta / roughness_threshold) + if not reasons: + continue + if last_review_frame is None or frame_index - last_review_frame > 2: + episode_index += 1 + last_review_frame = frame_index + for reason in reasons: + reason_counts[reason] += 1 + score = max(ratios) + chronological.append( + { + "rank": 0, + "frame_index": frame_index, + "source_frame_index": int( + source.arrays["source_frame_indices"][frame_index] + ), + "session_seconds": float( + source.arrays["session_seconds"][frame_index] + ), + "episode_id": f"episode-{episode_index:02d}", + "attention": ( + "high" + if score >= K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE + else "review" + ), + "attention_score": score, + "reasons": reasons, + "prediction": { + "available": prediction_available, + "residual_p50_m": float( + self.arrays["prediction_residual_p50_m"][frame_index] + ), + "residual_p95_m": prediction_p95, + "inlier_fraction": prediction_inlier, + }, + "temporal": { + "compared": temporal_compared, + "height_delta_m": height_delta, + "slope_delta_deg": slope_delta, + "roughness_delta_m": roughness_delta, + }, + "surface": { + "sensor_height_m": float( + self.arrays["sensor_height_m"][frame_index] + ), + "slope_deg": float(self.arrays["slope_deg"][frame_index]), + "roughness_m": float( + self.arrays["roughness_m"][frame_index] + ), + "confidence": float( + self.arrays["confidence"][frame_index] + ), + }, + "step_candidate_point_count": int( + self.arrays["step_candidate_point_count"][frame_index] + ), + } + ) + items = sorted( + chronological, + key=lambda item: ( + -cast(float, item["attention_score"]), + cast(int, item["frame_index"]), + ), + ) + for rank, item in enumerate(items, start=1): + item["rank"] = rank + high_attention_count = sum( + item["attention"] == "high" for item in items + ) + return { + "schema_version": K1_LOCAL_SURFACE_REVIEW_SCHEMA, + "review_profile_id": K1_LOCAL_SURFACE_REVIEW_PROFILE_ID, + "model_id": self.model_id, + "source_pack_id": source.pack_id, + "session_id": source.identity["session_id"], + "available": True, + "criteria": criteria, + "summary": { + "item_count": len(items), + "episode_count": episode_index, + "high_attention_count": high_attention_count, + "review_attention_count": len(items) - high_attention_count, + "reason_counts": reason_counts, + }, + "items": items, + "ground_truth": False, + "access": "read-only", + "authority": self.report["authority"], + } + + def _review_criteria(self) -> dict[str, float | int]: + profile = _object(self.identity.get("profile"), "K1 local-surface profile") + temporal = _object( + profile.get("temporal_qualification"), + "K1 local-surface temporal profile", + ) + return { + "prediction_tail_residual_p95_m": K1_LOCAL_SURFACE_REVIEW_TAIL_M, + "prediction_inlier_fraction_floor": ( + K1_LOCAL_SURFACE_REVIEW_INLIER_FLOOR + ), + "surface_height_jump_m": _positive_number( + temporal.get("height_jump_m"), + "K1 local-surface height jump threshold", + ), + "surface_slope_jump_deg": _positive_number( + temporal.get("slope_jump_deg"), + "K1 local-surface slope jump threshold", + ), + "surface_roughness_jump_m": _positive_number( + temporal.get("roughness_jump_m"), + "K1 local-surface roughness jump threshold", + ), + "high_attention_score": K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE, + "episode_max_frame_gap": 2, + } + def build_k1_local_surface( source: E10LidarFieldSource, @@ -1479,6 +1673,17 @@ def _nonnegative_int(value: object, label: str) -> int: return value +def _positive_number(value: object, label: str) -> float: + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(value) + or value <= 0.0 + ): + raise LidarGroundError(f"{label} is invalid") + return float(value) + + def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: diff --git a/src/k1link/web/lidar_api.py b/src/k1link/web/lidar_api.py index 9d76c25..f4be93c 100644 --- a/src/k1link/web/lidar_api.py +++ b/src/k1link/web/lidar_api.py @@ -600,6 +600,61 @@ def build_lidar_router( detail="K1 local-surface timeline не прошёл проверку целостности", ) from exc + @router.get("/local-surfaces/{model_id}/review") + def get_k1_local_surface_review(model_id: str) -> dict[str, object]: + if _LOCAL_SURFACE_ID.fullmatch(model_id) is None: + raise HTTPException( + status_code=404, + detail="K1 local-surface review не найден", + ) + model_root = local_surface_root_provider() + source_root = e10_source_root_provider() + if model_root is None or not model_root.is_dir(): + raise HTTPException( + status_code=503, + detail="K1 local-surface storage не настроен", + ) + if source_root is None or not source_root.is_dir(): + raise HTTPException( + status_code=503, + detail="E10 LiDAR source storage не настроен", + ) + model_path = model_root / model_id + if not model_path.is_dir(): + raise HTTPException( + status_code=404, + detail="K1 local-surface model не найден", + ) + try: + model = K1LocalSurfaceV1(model_path) + try: + source_pack_id = model.identity.get("source_pack_id") + if ( + not isinstance(source_pack_id, str) + or _E10_PACK_ID.fullmatch(source_pack_id) is None + ): + raise LidarGroundError("K1 local-surface source id is invalid") + source_path = source_root / source_pack_id + if not source_path.is_dir(): + raise HTTPException( + status_code=404, + detail="Связанный E10 LiDAR source не найден", + ) + source = E10LidarFieldSource(source_path) + try: + return model.review_detail(source) + finally: + source.close() + finally: + model.close() + except HTTPException: + raise + except (LidarGroundError, OSError) as exc: + raise HTTPException( + status_code=409, + detail="K1 local-surface review не прошёл проверку целостности", + ) from exc + @router.get("/local-surfaces/{model_id}/frames/{frame_index}") def get_k1_local_surface_frame( model_id: str, diff --git a/tests/test_lidar_local_surface.py b/tests/test_lidar_local_surface.py index 296268a..83d0182 100644 --- a/tests/test_lidar_local_surface.py +++ b/tests/test_lidar_local_surface.py @@ -159,6 +159,7 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only( source = E10LidarFieldSource(source_path) try: detail = model.frame_detail(source, 2) + review = model.review_detail(source) assert model.report["source"]["passive_processing_only"] is True assert model.report["source"]["firmware_or_device_commands_used"] is False assert model.report["surface_model"]["hardcoded_height_m"] is None @@ -180,8 +181,13 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only( assert detail["prediction"]["available"] is True assert detail["temporal"]["compared"] is True assert detail["authority"]["commands_enabled"] is False + assert review["available"] is True + assert review["ground_truth"] is False + assert review["criteria"]["prediction_inlier_fraction_floor"] == 0.85 + assert review["summary"]["item_count"] == len(review["items"]) assert "1.27" not in repr({"identity": model.identity, "report": model.report}) assert str(tmp_path) not in repr(detail) + assert str(tmp_path) not in repr(review) finally: source.close() model.close() @@ -202,9 +208,14 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only( router, "/api/v1/lidar/local-surfaces/{model_id}/timeline", ) + review_route = _endpoint( + router, + "/api/v1/lidar/local-surfaces/{model_id}/review", + ) catalog = catalog_route(limit=10) # type: ignore[operator] frame = frame_route(model_id=output.name, frame_index=2) # type: ignore[operator] timeline = timeline_route(model_id=output.name) # type: ignore[operator] + review = review_route(model_id=output.name) # type: ignore[operator] assert catalog["valid_total"] == 1 assert catalog["items"][0]["status"] == "diagnostic-only" assert frame["model_id"] == output.name @@ -213,3 +224,6 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only( assert sum(timeline["prediction_available"]) >= 4 assert len(timeline["temporal_jump"]) == 8 assert str(tmp_path) not in repr(timeline) + assert review["review_profile_id"] == "missioncore-local-surface-attention/v1" + assert review["access"] == "read-only" + assert str(tmp_path) not in repr(review)