From 796b9306f9abbda28cde44daa75f5ad69398958f Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 26 Jul 2026 00:36:38 +0300 Subject: [PATCH] feat: explain local surface residuals --- README.md | 6 + .../src/core/lidar/localSurface.ts | 118 +++++++- .../control-station/src/styles/workspaces.css | 50 ++++ .../src/workspaces/LidarGroundPointCloud.tsx | 208 +++++++++++++- .../src/workspaces/LidarLocalSurfacePanel.tsx | 106 ++++++- .../test/localSurface.test.mjs | 30 +- docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md | 45 ++- docs/14_LIDAR_DATASET_GATEWAY.md | 8 +- src/k1link/compute/lidar_local_surface.py | 260 ++++++++++++++++-- tests/test_lidar_local_surface.py | 12 + 10 files changed, 788 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index f6cfd3d..0da6ce5 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,12 @@ 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 selected frame can now switch to a prior-plane residual view: it preserves +the previous-TTL plane and overlays the current lower-cell evidence as +in-band, above-plane and below-plane observations. On source frame `1254`, +`36/98` evaluated cells are above the `0.16 m` band and none are below it, +localizing the heavy tail without naming an object or granting safety +authority. 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 0fc853b..bae12b2 100644 --- a/apps/control-station/src/core/lidar/localSurface.ts +++ b/apps/control-station/src/core/lidar/localSurface.ts @@ -118,6 +118,7 @@ export interface LidarLocalSurfaceFrame { }; surface: { planeCoefficientsMap: [number, number, number, number]; + localRadiusM: number; sensorHeightM: number; slopeDeg: number; roughnessM: number; @@ -140,6 +141,19 @@ export interface LidarLocalSurfaceFrame { residualP50M: number; residualP95M: number; inlierFraction: number; + evidence: { + available: boolean; + basis: "current-lower-cell-observations"; + coordinateFrame: "map"; + distanceUnit: "m"; + currentFrameExcludedFromPlane: true; + surfaceInlierBandM: number; + priorPlaneCoefficientsMap: [number, number, number, number]; + cellPointsXyzM: Array<[number, number, number]>; + cellSignedResidualM: number[]; + cellInlier: number[]; + groundTruth: false; + }; }; temporal: { compared: boolean; @@ -670,7 +684,7 @@ export function parseLidarLocalSurfaceFrame( ): LidarLocalSurfaceFrame { const source = record(value, "LiDAR local-surface frame"); if ( - source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-frame/v1` + source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-frame/v2` || source.access !== "read-only" || source.ground_truth !== false || source.coordinate_frame !== "map" @@ -731,6 +745,7 @@ export function parseLidarLocalSurfaceFrame( const pose = record(source.pose, "pose"); const surface = record(source.surface, "surface"); const prediction = record(source.prediction, "prediction"); + const evidence = record(prediction.evidence, "prediction.evidence"); const temporal = record(source.temporal, "temporal"); const counts = record(source.counts, "counts"); const parsedCounts = { @@ -764,6 +779,12 @@ export function parseLidarLocalSurfaceFrame( 4, "surface.plane_coefficients_map", ); + const localRadiusM = finite(surface.local_radius_m, "surface.local_radius_m"); + if (localRadiusM <= 0) { + throw new LidarLocalSurfaceContractError( + "Local-surface radius несовместим", + ); + } if (prediction.current_frame_excluded !== true) { throw new LidarLocalSurfaceContractError( "Текущий кадр попал в prediction input", @@ -778,6 +799,82 @@ export function parseLidarLocalSurfaceFrame( "Prediction inlier fraction несовместим", ); } + if ( + evidence.basis !== "current-lower-cell-observations" + || evidence.coordinate_frame !== "map" + || evidence.distance_unit !== "m" + || evidence.current_frame_excluded_from_plane !== true + || evidence.ground_truth !== false + ) { + throw new LidarLocalSurfaceContractError( + "Prediction evidence несовместим", + ); + } + const evidenceAvailable = boolean(evidence.available, "evidence.available"); + const surfaceInlierBandM = finite( + evidence.surface_inlier_band_m, + "evidence.surface_inlier_band_m", + ); + if (surfaceInlierBandM <= 0) { + throw new LidarLocalSurfaceContractError( + "Prediction evidence band несовместим", + ); + } + const priorPlane = tuple( + evidence.prior_plane_coefficients_map, + 4, + "evidence.prior_plane_coefficients_map", + ); + const evidencePoints = array( + evidence.cell_points_xyz_m, + "evidence.cell_points_xyz_m", + ).map((item, index): [number, number, number] => { + const values = tuple(item, 3, `evidence.cell_points_xyz_m[${index}]`); + return [values[0], values[1], values[2]]; + }); + const evidenceResiduals = array( + evidence.cell_signed_residual_m, + "evidence.cell_signed_residual_m", + ).map((item, index) => finite( + item, + `evidence.cell_signed_residual_m[${index}]`, + )); + const evidenceInlier = array( + evidence.cell_inlier, + "evidence.cell_inlier", + ).map((item, index) => { + const parsed = integer(item, `evidence.cell_inlier[${index}]`); + if (parsed > 1) { + throw new LidarLocalSurfaceContractError( + "Prediction evidence mask несовместим", + ); + } + return parsed; + }); + if ( + evidencePoints.length !== evidenceResiduals.length + || evidencePoints.length !== evidenceInlier.length + || evidenceInlier.some( + (item, index) => + item !== (Math.abs(evidenceResiduals[index]) <= surfaceInlierBandM ? 1 : 0), + ) + || ( + evidenceAvailable + ? !boolean(prediction.available, "prediction.available") + || evidencePoints.length !== integer( + prediction.cell_count, + "prediction.cell_count", + ) + : evidencePoints.length !== 0 + || evidenceResiduals.length !== 0 + || evidenceInlier.length !== 0 + || priorPlane.some((value) => value !== 0) + ) + ) { + throw new LidarLocalSurfaceContractError( + "Prediction evidence arrays расходятся", + ); + } return { modelId: text(source.model_id, "model_id", SAFE_MODEL_ID), sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID), @@ -808,6 +905,7 @@ export function parseLidarLocalSurfaceFrame( }, surface: { planeCoefficientsMap: [plane[0], plane[1], plane[2], plane[3]], + localRadiusM, sensorHeightM: finite(surface.sensor_height_m, "surface.sensor_height_m"), slopeDeg: finite(surface.slope_deg, "surface.slope_deg"), roughnessM: finite(surface.roughness_m, "surface.roughness_m"), @@ -836,6 +934,24 @@ export function parseLidarLocalSurfaceFrame( "prediction.residual_p95_m", ), inlierFraction: predictionInlierFraction, + evidence: { + available: evidenceAvailable, + basis: "current-lower-cell-observations", + coordinateFrame: "map", + distanceUnit: "m", + currentFrameExcludedFromPlane: true, + surfaceInlierBandM, + priorPlaneCoefficientsMap: [ + priorPlane[0], + priorPlane[1], + priorPlane[2], + priorPlane[3], + ], + cellPointsXyzM: evidencePoints, + cellSignedResidualM: evidenceResiduals, + cellInlier: evidenceInlier, + groundTruth: false, + }, }, temporal: { compared: boolean(temporal.compared, "temporal.compared"), diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index 1f650e0..0880f6b 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -3108,6 +3108,40 @@ line-height: 1.45; } +.lidar-local-surface__view-switch { + display: flex; + align-items: center; + gap: 0.32rem; +} + +.lidar-local-surface__view-switch button { + border: 0; + border-radius: 999px; + background: rgb(255 255 255 / 0.035); + color: var(--nodedc-text-muted); + padding: 0.38rem 0.58rem; + font-size: 0.56rem; +} + +.lidar-local-surface__view-switch button:hover, +.lidar-local-surface__view-switch button:focus-visible, +.lidar-local-surface__view-switch button[aria-pressed="true"] { + outline: 0; + background: rgb(255 255 255 / 0.09); + color: var(--nodedc-text-primary); +} + +.lidar-local-surface__view-switch button:disabled { + opacity: 0.35; + cursor: default; +} + +.lidar-local-surface__view-switch span { + margin-left: auto; + color: var(--nodedc-text-muted); + font-size: 0.56rem; +} + .lidar-local-surface__stage { display: grid; overflow: hidden; @@ -3189,6 +3223,22 @@ background: #a85061; } +.lidar-local-surface__legend i[data-class="residual-inlier"] { + background: #9ebd7d; +} + +.lidar-local-surface__legend i[data-class="prior-plane"] { + background: rgb(158 189 125 / 0.32); +} + +.lidar-local-surface__legend i[data-class="residual-above"] { + background: #f57d3b; +} + +.lidar-local-surface__legend i[data-class="residual-below"] { + background: #b85c80; +} + .lidar-fallback-review { display: grid; overflow: hidden; diff --git a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx index 1292245..1dde48e 100644 --- a/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx +++ b/apps/control-station/src/workspaces/LidarGroundPointCloud.tsx @@ -10,7 +10,8 @@ export type LidarGroundViewMode = | "candidate-disagreement" | "semantic" | "ground-truth" - | "local-surface"; + | "local-surface" + | "prediction-residual"; export interface LidarGroundPointCloudFrame { pointCount: number; @@ -29,6 +30,12 @@ export interface LidarGroundPointCloudFrame { localSurfaceClass?: number[]; localStepCandidate?: number[]; }; + predictionEvidence?: { + pointsXyzM: Array<[number, number, number]>; + signedResidualM: number[]; + inlierBandM: number; + priorPlaneCoefficientsMap: [number, number, number, number]; + }; } interface LidarGroundPointCloudProps { @@ -70,7 +77,9 @@ function frameColors( const current = frame.masks.currentGround[index] === 1; const candidate = frame.masks.candidateGround[index] === 1; const candidateAssigned = frame.masks.candidateAssigned[index] === 1; - if (mode === "local-surface") { + if (mode === "prediction-residual") { + setRgb(colors, offset, 0.22, 0.23, 0.22); + } else if (mode === "local-surface") { const localClass = frame.masks.localSurfaceClass?.[index] ?? 0; const stepCandidate = frame.masks.localStepCandidate?.[index] === 1; if (stepCandidate) { @@ -162,6 +171,24 @@ function frameColors( return colors; } +function predictionEvidenceColors( + signedResidualM: number[], + inlierBandM: number, +): Float32Array { + const colors = new Float32Array(signedResidualM.length * 3); + signedResidualM.forEach((residual, index) => { + const offset = index * 3; + if (Math.abs(residual) <= inlierBandM) { + setRgb(colors, offset, 0.62, 0.74, 0.49); + } else if (residual > 0) { + setRgb(colors, offset, 0.96, 0.49, 0.23); + } else { + setRgb(colors, offset, 0.72, 0.36, 0.5); + } + }); + return colors; +} + export function LidarGroundPointCloud({ frame, mode, @@ -171,6 +198,12 @@ export function LidarGroundPointCloud({ const hostRef = useRef(null); const geometryRef = useRef(null); const materialRef = useRef(null); + const evidenceGeometryRef = useRef(null); + const evidenceMaterialRef = useRef(null); + const evidencePointsRef = useRef(null); + const priorPlaneGeometryRef = useRef(null); + const priorPlaneMaterialRef = useRef(null); + const priorPlaneMeshRef = useRef(null); const cameraRef = useRef(null); const controlsRef = useRef(null); const fogRef = useRef(null); @@ -238,6 +271,47 @@ export function LidarGroundPointCloud({ materialRef.current = material; scene.add(new THREE.Points(geometry, material)); + const evidenceGeometry = new THREE.BufferGeometry(); + evidenceGeometryRef.current = evidenceGeometry; + const evidenceMaterial = new THREE.PointsMaterial({ + size: 0.075, + sizeAttenuation: true, + vertexColors: true, + transparent: true, + opacity: 1, + depthWrite: true, + fog: false, + }); + evidenceMaterialRef.current = evidenceMaterial; + const evidencePoints = new THREE.Points( + evidenceGeometry, + evidenceMaterial, + ); + evidencePoints.visible = false; + evidencePoints.renderOrder = 1; + evidencePointsRef.current = evidencePoints; + scene.add(evidencePoints); + + const priorPlaneGeometry = new THREE.BufferGeometry(); + priorPlaneGeometryRef.current = priorPlaneGeometry; + const priorPlaneMaterial = new THREE.MeshBasicMaterial({ + color: 0x9ebd7d, + transparent: true, + opacity: 0.18, + depthWrite: false, + side: THREE.DoubleSide, + fog: false, + }); + priorPlaneMaterialRef.current = priorPlaneMaterial; + const priorPlaneMesh = new THREE.Mesh( + priorPlaneGeometry, + priorPlaneMaterial, + ); + priorPlaneMesh.visible = false; + priorPlaneMesh.renderOrder = 0; + priorPlaneMeshRef.current = priorPlaneMesh; + scene.add(priorPlaneMesh); + const grid = new THREE.GridHelper(10, 40, 0x454846, 0x252725); gridRef.current = grid; const gridMaterials = Array.isArray(grid.material) @@ -278,6 +352,10 @@ export function LidarGroundPointCloud({ controls.dispose(); geometry.dispose(); material.dispose(); + evidenceGeometry.dispose(); + evidenceMaterial.dispose(); + priorPlaneGeometry.dispose(); + priorPlaneMaterial.dispose(); grid.geometry.dispose(); gridMaterials.forEach((gridMaterial) => gridMaterial.dispose()); axes.geometry.dispose(); @@ -289,6 +367,12 @@ export function LidarGroundPointCloud({ renderer.domElement.remove(); geometryRef.current = null; materialRef.current = null; + evidenceGeometryRef.current = null; + evidenceMaterialRef.current = null; + evidencePointsRef.current = null; + priorPlaneGeometryRef.current = null; + priorPlaneMaterialRef.current = null; + priorPlaneMeshRef.current = null; cameraRef.current = null; controlsRef.current = null; fogRef.current = null; @@ -302,11 +386,24 @@ export function LidarGroundPointCloud({ useEffect(() => { const geometry = geometryRef.current; const material = materialRef.current; + const evidenceGeometry = evidenceGeometryRef.current; + const evidenceMaterial = evidenceMaterialRef.current; + const priorPlaneGeometry = priorPlaneGeometryRef.current; const camera = cameraRef.current; const controls = controlsRef.current; const fog = fogRef.current; const grid = gridRef.current; - if (!geometry || !material || !camera || !controls || !fog || !grid) return; + if ( + !geometry + || !material + || !evidenceGeometry + || !evidenceMaterial + || !priorPlaneGeometry + || !camera + || !controls + || !fog + || !grid + ) return; const positions = new Float32Array(frame.pointCount * 3); const sampleStep = Math.max(1, Math.floor(frame.pointCount / 5_000)); @@ -341,6 +438,62 @@ export function LidarGroundPointCloud({ }); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.computeBoundingSphere(); + const evidencePositions = new Float32Array( + (frame.predictionEvidence?.pointsXyzM.length ?? 0) * 3, + ); + frame.predictionEvidence?.pointsXyzM.forEach(([x, y, z], index) => { + const offset = index * 3; + evidencePositions[offset] = x - centerX; + evidencePositions[offset + 1] = coordinateFrame === "sensor-fixed" + ? z + sensorHeightM + : z - minimumZ; + evidencePositions[offset + 2] = -(y - centerY); + }); + evidenceGeometry.setAttribute( + "position", + new THREE.BufferAttribute(evidencePositions, 3), + ); + evidenceGeometry.computeBoundingSphere(); + const evidence = frame.predictionEvidence; + const [planeA, planeB, planeC, planeD] = ( + evidence?.priorPlaneCoefficientsMap ?? [0, 0, 0, 0] + ); + if (evidence && Math.abs(planeC) > 1e-6 && evidence.pointsXyzM.length) { + const evidenceX = evidence.pointsXyzM.map((point) => point[0]); + const evidenceY = evidence.pointsXyzM.map((point) => point[1]); + const planeMinimumX = percentile(evidenceX, 0.02); + const planeMaximumX = percentile(evidenceX, 0.98); + const planeMinimumY = percentile(evidenceY, 0.02); + const planeMaximumY = percentile(evidenceY, 0.98); + const corners: Array<[number, number]> = [ + [planeMinimumX, planeMinimumY], + [planeMaximumX, planeMinimumY], + [planeMinimumX, planeMaximumY], + [planeMaximumX, planeMaximumY], + ]; + const planePositions = new Float32Array(12); + corners.forEach(([x, y], index) => { + const z = -(planeA * x + planeB * y + planeD) / planeC; + const offset = index * 3; + planePositions[offset] = x - centerX; + planePositions[offset + 1] = coordinateFrame === "sensor-fixed" + ? z + sensorHeightM + : z - minimumZ; + planePositions[offset + 2] = -(y - centerY); + }); + priorPlaneGeometry.setAttribute( + "position", + new THREE.BufferAttribute(planePositions, 3), + ); + priorPlaneGeometry.setIndex([0, 1, 2, 2, 1, 3]); + priorPlaneGeometry.computeVertexNormals(); + } else { + priorPlaneGeometry.setAttribute( + "position", + new THREE.BufferAttribute(new Float32Array(0), 3), + ); + priorPlaneGeometry.setIndex([]); + } const radius = Math.max( Math.hypot( (maximumX - minimumX) / 2, @@ -351,12 +504,25 @@ export function LidarGroundPointCloud({ ); viewRadiusRef.current = radius; material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075); + evidenceMaterial.size = THREE.MathUtils.clamp( + radius / 45, + 0.08, + 0.28, + ); fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035); grid.scale.setScalar(Math.max(radius / 5, 0.25)); - const targetHeight = coordinateFrame === "sensor-fixed" - ? Math.max(sensorHeightM * 0.35, 0.35) - : Math.max((maximumZ - minimumZ) * 0.42, 0.15); + const evidenceHeight = frame.predictionEvidence?.pointsXyzM.length + ? percentile( + frame.predictionEvidence.pointsXyzM.map((point) => point[2]), + 0.5, + ) - minimumZ + : 0; + const targetHeight = mode === "prediction-residual" + ? Math.max(evidenceHeight + 0.45, 0.35) + : coordinateFrame === "sensor-fixed" + ? Math.max(sensorHeightM * 0.35, 0.35) + : Math.max((maximumZ - minimumZ) * 0.42, 0.15); const distance = Math.max(radius * 1.15, 0.9); viewTargetHeightRef.current = targetHeight; camera.near = Math.max(distance / 1_000, 0.005); @@ -368,16 +534,42 @@ export function LidarGroundPointCloud({ viewInitializedRef.current = true; } controls.update(); - }, [coordinateFrame, frame, sensorHeightM]); + }, [coordinateFrame, frame, mode, sensorHeightM]); useEffect(() => { const geometry = geometryRef.current; - if (!geometry) return; + const material = materialRef.current; + const evidenceGeometry = evidenceGeometryRef.current; + const evidencePoints = evidencePointsRef.current; + const priorPlaneMesh = priorPlaneMeshRef.current; + if ( + !geometry + || !material + || !evidenceGeometry + || !evidencePoints + || !priorPlaneMesh + ) return; geometry.setAttribute( "color", new THREE.BufferAttribute(frameColors(frame, mode), 3), ); geometry.attributes.color.needsUpdate = true; + material.opacity = mode === "prediction-residual" ? 0.18 : 0.96; + const evidence = frame.predictionEvidence; + evidencePoints.visible = mode === "prediction-residual" + && Boolean(evidence?.pointsXyzM.length); + priorPlaneMesh.visible = evidencePoints.visible; + const evidenceColors = evidence + ? predictionEvidenceColors( + evidence.signedResidualM, + evidence.inlierBandM, + ) + : new Float32Array(0); + evidenceGeometry.setAttribute( + "color", + new THREE.BufferAttribute(evidenceColors, 3), + ); + evidenceGeometry.attributes.color.needsUpdate = true; }, [frame, mode]); const resetCamera = () => { diff --git a/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx b/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx index f24806c..4359ad2 100644 --- a/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx +++ b/apps/control-station/src/workspaces/LidarLocalSurfacePanel.tsx @@ -42,6 +42,9 @@ export function LidarLocalSurfacePanel({ const [selectedFrameIndex, setSelectedFrameIndex] = useState( null, ); + const [surfaceView, setSurfaceView] = useState< + "local-surface" | "prediction-residual" + >("local-surface"); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -143,10 +146,17 @@ export function LidarLocalSurfacePanel({ const cloudFrame = useMemo(() => { if (!frame) return null; - const emptyMask = new Array(frame.pointCount).fill(0); + const [poseX, poseY] = frame.pose.positionXyzM; + const radiusSquared = frame.surface.localRadiusM ** 2; + const selectedIndices = frame.pointsXyzM.flatMap(([x, y], index) => + (x - poseX) ** 2 + (y - poseY) ** 2 <= radiusSquared + ? [index] + : [], + ); + const emptyMask = new Array(selectedIndices.length).fill(0); return { - pointCount: frame.pointCount, - pointsXyzM: frame.pointsXyzM, + pointCount: selectedIndices.length, + pointsXyzM: selectedIndices.map((index) => frame.pointsXyzM[index]), intensity0To255: null, masks: { currentGround: emptyMask, @@ -154,9 +164,22 @@ export function LidarLocalSurfacePanel({ candidateGround: emptyMask, candidateAssigned: emptyMask, disagreement: emptyMask, - localSurfaceClass: frame.pointClass, - localStepCandidate: frame.pointStepCandidate, + localSurfaceClass: selectedIndices.map( + (index) => frame.pointClass[index], + ), + localStepCandidate: selectedIndices.map( + (index) => frame.pointStepCandidate[index], + ), }, + predictionEvidence: frame.prediction.evidence.available + ? { + pointsXyzM: frame.prediction.evidence.cellPointsXyzM, + signedResidualM: frame.prediction.evidence.cellSignedResidualM, + inlierBandM: frame.prediction.evidence.surfaceInlierBandM, + priorPlaneCoefficientsMap: + frame.prediction.evidence.priorPlaneCoefficientsMap, + } + : undefined, }; }, [frame]); const selectedReviewItem = useMemo( @@ -165,6 +188,16 @@ export function LidarLocalSurfacePanel({ ) ?? null, [review, selectedFrameIndex], ); + useEffect(() => { + const hasPredictionReason = selectedReviewItem?.reasons.some( + (reason) => reason.startsWith("prediction-"), + ); + setSurfaceView( + hasPredictionReason && frame?.prediction.evidence.available + ? "prediction-residual" + : "local-surface", + ); + }, [frame?.prediction.evidence.available, selectedReviewItem]); if (!model && !loading && !error) { return null; @@ -266,9 +299,38 @@ export function LidarLocalSurfacePanel({ ) : null} + {frame ? ( +
+ + + + {frame.prediction.evidence.available + ? `${frame.prediction.cellCount.toLocaleString("ru-RU")} проверенных ячеек` + : "Для этого кадра нет prior-plane"} + +
+ ) : null} +
{cloudFrame && frame ? ( - + ) : (
@@ -285,6 +347,10 @@ export function LidarLocalSurfacePanel({ t = {formatNumber(frame.sessionSeconds)} с
+
+
Локальный радиус
+
{formatNumber(frame.surface.localRadiusM, 1)} м
+
Высота
{formatNumber(frame.surface.sensorHeightM)} м
@@ -333,16 +399,28 @@ export function LidarLocalSurfacePanel({
- Перепад / бордюр-кандидат - Наблюдаемая поверхность - Выше поверхности - Нижний выброс - Не классифицировано + {surfaceView === "prediction-residual" ? ( + <> + Prior-plane + В prior-band + Выше prior-plane + Ниже prior-plane + Контекст кадра + + ) : ( + <> + Перепад / бордюр-кандидат + Наблюдаемая поверхность + Выше поверхности + Нижний выброс + Не классифицировано + + )}

- Жёлтый слой — геометрический кандидат, не распознанный бордюр и - не ground truth. Пустота остаётся unknown; движение этим слоем - не разрешается. + {surfaceView === "prediction-residual" + ? "Полупрозрачная плоскость построена только по предыдущему TTL-окну. Крупные точки — нижние cell-наблюдения текущего кадра: оранжевое выше, пурпурное ниже; это объяснение residual, не разметка препятствий." + : "Жёлтый слой — геометрический кандидат, не распознанный бордюр и не ground truth. Пустота остаётся unknown; движение этим слоем не разрешается."}

) : null} diff --git a/apps/control-station/test/localSurface.test.mjs b/apps/control-station/test/localSurface.test.mjs index 9197c2e..0101c4b 100644 --- a/apps/control-station/test/localSurface.test.mjs +++ b/apps/control-station/test/localSurface.test.mjs @@ -136,7 +136,7 @@ function catalog(overrides = {}) { function frame(overrides = {}) { return { - schema_version: "missioncore.k1-local-surface-frame/v1", + schema_version: "missioncore.k1-local-surface-frame/v2", model_id: modelId, source_pack_id: sourcePackId, session_id: "20260720T065719Z_viewer_live", @@ -161,6 +161,7 @@ function frame(overrides = {}) { }, surface: { plane_coefficients_map: [0, 0, 1, 0], + local_radius_m: 10, sensor_height_m: 1.3, slope_deg: 0, roughness_m: 0.02, @@ -179,10 +180,23 @@ function frame(overrides = {}) { prediction: { available: true, current_frame_excluded: true, - cell_count: 32, + cell_count: 2, residual_p50_m: 0.04, residual_p95_m: 0.2, inlier_fraction: 0.95, + evidence: { + available: true, + basis: "current-lower-cell-observations", + coordinate_frame: "map", + distance_unit: "m", + current_frame_excluded_from_plane: true, + surface_inlier_band_m: 0.16, + prior_plane_coefficients_map: [0, 0, 1, 0], + cell_points_xyz_m: [[0.25, 0.25, 0.02], [1.25, 0.25, 0.18]], + cell_signed_residual_m: [0.02, 0.18], + cell_inlier: [1, 0], + ground_truth: false, + }, }, temporal: { compared: true, @@ -374,6 +388,9 @@ test("decodes passive local-surface evidence", () => { assert.equal(decodedFrame.counts.occupied, 1); assert.equal(decodedFrame.counts.stepCandidate, 1); assert.equal(decodedFrame.prediction.currentFrameExcluded, true); + assert.equal(decodedFrame.prediction.evidence.available, true); + assert.equal(decodedFrame.prediction.evidence.cellPointsXyzM.length, 2); + assert.deepEqual(decodedFrame.prediction.evidence.cellInlier, [1, 0]); assert.equal(decodedFrame.surface.sensorHeightM, 1.3); const decodedTimeline = parseLidarLocalSurfaceTimeline(timeline()); @@ -413,6 +430,15 @@ test("rejects command authority", () => { ); }); +test("rejects forged prediction residual evidence", () => { + const forged = frame(); + forged.prediction.evidence.cell_inlier = [0, 0]; + assert.throws( + () => parseLidarLocalSurfaceFrame(forged), + LidarLocalSurfaceContractError, + ); +}); + test("fetches the complete local-surface timeline read-only", async () => { const requests = []; const decoded = await fetchLidarLocalSurfaceTimeline(modelId, { diff --git a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md index d77f74f..22b1b78 100644 --- a/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md +++ b/docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md @@ -1,10 +1,10 @@ # LiDAR worker: product value, evidence boundary and implementation roadmap -Date: 2026-07-25 +Date: 2026-07-26 Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B -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 +complete; full GOOSE and RELLIS qualification complete; L2.6d K1 replay +local-surface temporal qualification, operator triage and prior-plane residual +explainability implemented; bounded 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 @@ -414,7 +414,7 @@ Dataset expansion is no longer the next gate. - [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 +- [x] 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, @@ -474,8 +474,31 @@ not merely a one-frame numerical spike. Source frames `1253–1254` reach 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. +failures. + +The L2.6d derivative +`k1-local-surface-628cd024775f02fea99765d1fb457efec2d5cc4d7371e56818dfe8e08c9b3b74` +preserves the exact prior-only plane and signed residual for every evaluated +lower-cell observation. The frame API exposes this evidence read-only and +source-aligned; the browser overlays a translucent prior-plane plus enlarged +in-band, above-plane and below-plane cells over a dimmed source-frame cloud. +The current frame remains excluded from plane input. + +This changes the episode interpretation. Source frame `1254` evaluates `98` +cells: `36` are above the `0.16 m` prior-band and none are below it. Frame +`1253` has `31/97` above-band cells and none below. Their above-band centroids +are approximately `(3.06, 0.95) m` and `(2.46, 0.65) m` from the recorded +sensor position in map XY. The large tail is therefore a spatially localized, +positive structure entering the lower-cell evidence, not a symmetric global +plane wobble. This still does not identify the physical object without +independent ground truth. Frame `1195` instead has only seven out-of-band cells +(`5` above, `2` below) while the fitted surface slope and roughness move +together, preserving its separate interpretation as a surface-regime +transition. + +No fit threshold was changed after this review. The next gate is a bounded +latest-wins live-shadow queue using the same profile and evidence contract, +still without commands, free-space, navigation or safety authority. Exit: one immutable K1 session yields both a persistent reconstruction and a bounded local world state without hard-coded terrain height or scanner-side @@ -563,8 +586,10 @@ 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. 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, +inspection set to four high-priority frames in two episodes. Prior-plane +residual explainability now shows that episode `09` is a localized positive +structure rather than symmetric plane drift. The highest-value immediate work +is therefore a bounded live-shadow queue, followed by a 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 721a459..30007cd 100644 --- a/docs/14_LIDAR_DATASET_GATEWAY.md +++ b/docs/14_LIDAR_DATASET_GATEWAY.md @@ -54,6 +54,10 @@ Implemented now: - 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; +- point/cell-aligned prediction evidence in + `missioncore.k1-local-surface-frame/v2`: the prior-only plane, current + lower-cell coordinates, signed residual and derived inlier mask are + content-bound and visible as a read-only 3D overlay; - a provider-neutral read-only local-surface view in **Парк → Диагностика LiDAR**, synchronized to the five existing RAVNOVES00 scene selectors and a clickable complete-recording timeline; @@ -89,8 +93,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. +- no bounded live-shadow execution of the accepted K1 local-surface profile + yet; the residual overlay remains replay-only and non-authoritative. ## Product surface boundary diff --git a/src/k1link/compute/lidar_local_surface.py b/src/k1link/compute/lidar_local_surface.py index d83e177..241e4fa 100644 --- a/src/k1link/compute/lidar_local_surface.py +++ b/src/k1link/compute/lidar_local_surface.py @@ -27,7 +27,7 @@ from .lidar_field_review import ( 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_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v2" 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" @@ -180,6 +180,26 @@ class K1LocalSurfaceProfile: DEFAULT_K1_LOCAL_SURFACE_PROFILE: Final = K1LocalSurfaceProfile() +@dataclass(frozen=True, slots=True) +class _PredictionEvidence: + """Current lower-cell evidence scored against a prior-only surface.""" + + prior_plane: npt.NDArray[np.float64] + cell_points: npt.NDArray[np.float64] + signed_residuals: npt.NDArray[np.float64] + + @property + def residual_p50_m(self) -> float: + return float(np.percentile(np.abs(self.signed_residuals), 50)) + + @property + def residual_p95_m(self) -> float: + return float(np.percentile(np.abs(self.signed_residuals), 95)) + + def inlier_fraction(self, surface_band_m: float) -> float: + return float(np.mean(np.abs(self.signed_residuals) <= surface_band_m)) + + class K1LocalSurfaceV1: """Strict reader for source-aligned, passive K1 local-surface evidence.""" @@ -253,10 +273,21 @@ class K1LocalSurfaceV1: "step_candidate_point_count", "point_step_candidate", } + prediction_evidence = { + "prediction_prior_plane_coefficients_map", + "prediction_cell_offsets", + "prediction_cell_points_map", + "prediction_cell_signed_residual_m", + } files = set(self.arrays.files) - if files not in (baseline, baseline | qualification): + if files not in ( + baseline, + baseline | qualification, + baseline | qualification | prediction_evidence, + ): raise LidarGroundError("K1 local-surface arrays are incomplete") self.has_temporal_qualification = qualification <= files + self.has_prediction_evidence = prediction_evidence <= files vector_f64 = ( "sensor_height_m", "slope_deg", @@ -368,6 +399,63 @@ class K1LocalSurfaceV1: ) ): raise LidarGroundError("K1 local-surface step candidates are invalid") + if self.has_prediction_evidence: + offsets = self.arrays["prediction_cell_offsets"] + points = self.arrays["prediction_cell_points_map"] + residuals = self.arrays["prediction_cell_signed_residual_m"] + planes = self.arrays["prediction_prior_plane_coefficients_map"] + if ( + not self.has_temporal_qualification + or offsets.shape != (frame_count + 1,) + or offsets.dtype != np.dtype(" dict[str, object]: + surface_band_m = self._surface_band_m() + if not self.has_prediction_evidence: + return { + "available": False, + "basis": "current-lower-cell-observations", + "coordinate_frame": "map", + "distance_unit": "m", + "current_frame_excluded_from_plane": True, + "surface_inlier_band_m": surface_band_m, + "prior_plane_coefficients_map": [0.0, 0.0, 0.0, 0.0], + "cell_points_xyz_m": [], + "cell_signed_residual_m": [], + "cell_inlier": [], + "ground_truth": False, + } + offsets = self.arrays["prediction_cell_offsets"] + start = int(offsets[frame_index]) + end = int(offsets[frame_index + 1]) + residuals = self.arrays["prediction_cell_signed_residual_m"][start:end] + return { + "available": bool(self.arrays["prediction_available"][frame_index]), + "basis": "current-lower-cell-observations", + "coordinate_frame": "map", + "distance_unit": "m", + "current_frame_excluded_from_plane": True, + "surface_inlier_band_m": surface_band_m, + "prior_plane_coefficients_map": self.arrays[ + "prediction_prior_plane_coefficients_map" + ][frame_index] + .astype(np.float64) + .tolist(), + "cell_points_xyz_m": self.arrays["prediction_cell_points_map"][start:end] + .astype(np.float64) + .tolist(), + "cell_signed_residual_m": residuals.astype(np.float64).tolist(), + "cell_inlier": (np.abs(residuals) <= surface_band_m) + .astype(np.int64) + .tolist(), + "ground_truth": False, + } + def timeline_detail(self, source: E10LidarFieldSource) -> dict[str, object]: _validate_source_binding(self, source) frame_count = source.frame_count @@ -825,6 +961,28 @@ class K1LocalSurfaceV1: "episode_max_frame_gap": 2, } + def _surface_band_m(self) -> float: + profile = _object(self.identity.get("profile"), "K1 local-surface profile") + classification = _object( + profile.get("classification"), + "K1 local-surface classification profile", + ) + return _positive_number( + classification.get("surface_band_m"), + "K1 local-surface band", + ) + + def _local_radius_m(self) -> float: + profile = _object(self.identity.get("profile"), "K1 local-surface profile") + rolling_surface = _object( + profile.get("rolling_surface"), + "K1 local-surface rolling profile", + ) + return _positive_number( + rolling_surface.get("local_radius_m"), + "K1 local-surface local radius", + ) + def build_k1_local_surface( source: E10LidarFieldSource, @@ -850,6 +1008,12 @@ def build_k1_local_surface( pose_delta = np.abs(source_arrays["pose_point_delta_ms"]) cache: dict[tuple[int, int], tuple[float, float]] = {} previous_surface: tuple[float, float, float, float] | None = None + prediction_cell_points: list[npt.NDArray[np.float64]] = [ + np.empty((0, 3), dtype=np.float64) for _ in range(frame_count) + ] + prediction_cell_residuals: list[npt.NDArray[np.float64]] = [ + np.empty(0, dtype=np.float64) for _ in range(frame_count) + ] for frame_index in range(frame_count): start = int(offsets[frame_index]) @@ -883,12 +1047,24 @@ def build_k1_local_surface( profile, ) if prediction is not None: - residual_p50, residual_p95, inlier_fraction, prediction_cells = prediction + prediction_cell_points[frame_index] = prediction.cell_points + prediction_cell_residuals[frame_index] = prediction.signed_residuals arrays["prediction_available"][frame_index] = True - arrays["prediction_cell_count"][frame_index] = prediction_cells - arrays["prediction_residual_p50_m"][frame_index] = residual_p50 - arrays["prediction_residual_p95_m"][frame_index] = residual_p95 - arrays["prediction_inlier_fraction"][frame_index] = inlier_fraction + arrays["prediction_cell_count"][frame_index] = ( + prediction.cell_points.shape[0] + ) + arrays["prediction_residual_p50_m"][ + frame_index + ] = prediction.residual_p50_m + arrays["prediction_residual_p95_m"][ + frame_index + ] = prediction.residual_p95_m + arrays["prediction_inlier_fraction"][ + frame_index + ] = prediction.inlier_fraction(profile.surface_band_m) + arrays["prediction_prior_plane_coefficients_map"][ + frame_index + ] = prediction.prior_plane _update_cache(cache, local_cloud, session_seconds, profile) cell_keys, cell_points, cell_times = _local_cache_records( cache, @@ -987,6 +1163,12 @@ def build_k1_local_surface( np.count_nonzero(step_candidate) ) + arrays.update( + _prediction_evidence_arrays( + prediction_cell_points, + prediction_cell_residuals, + ) + ) logical_content_sha256 = _logical_sha256(arrays) valid = arrays["frame_valid"] identity = { @@ -1145,8 +1327,8 @@ def build_k1_local_surface( "status": "replay-experiment-only", "production_promotion": False, "next_gate": ( - "review the full recording, qualify local-surface stability, then run " - "bounded latest-wins live shadow without commands" + "run the accepted profile through a bounded latest-wins live-shadow " + "queue without commands, free-space or safety authority" ), }, "authority": { @@ -1230,6 +1412,10 @@ def _empty_arrays( "prediction_residual_p50_m": np.zeros(frame_count, dtype=" dict[str, npt.NDArray[Any]]: + if len(cell_points) != len(signed_residuals): + raise LidarGroundError("K1 local-surface prediction evidence is unaligned") + offsets = np.zeros(len(cell_points) + 1, dtype=" tuple[float, float, float, int] | None: +) -> _PredictionEvidence | None: """Score current lower-cell evidence against a plane built without that frame.""" if ( @@ -1429,16 +1652,17 @@ def _prediction_metrics( evaluation = current_cell_points[:, 2] <= cutoff if int(np.count_nonzero(evaluation)) < profile.minimum_surface_cells: return None - residual = np.abs( - _height_above_plane(current_cell_points[evaluation], prior_plane) + evaluation_points = current_cell_points[evaluation] + signed_residuals = _height_above_plane( + evaluation_points, + prior_plane, ) - if residual.size == 0 or not np.isfinite(residual).all(): + if signed_residuals.size == 0 or not np.isfinite(signed_residuals).all(): return None - return ( - float(np.percentile(residual, 50)), - float(np.percentile(residual, 95)), - float(np.mean(residual <= profile.surface_band_m)), - int(residual.shape[0]), + return _PredictionEvidence( + prior_plane=prior_plane, + cell_points=evaluation_points, + signed_residuals=signed_residuals, ) diff --git a/tests/test_lidar_local_surface.py b/tests/test_lidar_local_surface.py index 83d0182..a1699a5 100644 --- a/tests/test_lidar_local_surface.py +++ b/tests/test_lidar_local_surface.py @@ -173,12 +173,24 @@ def test_k1_local_surface_is_dynamic_source_bound_and_read_only( assert temporal["step_candidates"]["is_ground_truth"] is False assert temporal["step_candidates"]["frames_with_candidates"] > 0 assert detail["valid"] is True + assert detail["surface"]["local_radius_m"] == 5.0 assert 1.2 < detail["surface"]["sensor_height_m"] < 1.6 assert detail["counts"]["surface"] > 50 assert detail["counts"]["occupied"] > 0 assert detail["counts"]["step_candidate"] > 0 assert detail["prediction"]["current_frame_excluded"] is True assert detail["prediction"]["available"] is True + evidence = detail["prediction"]["evidence"] + assert evidence["available"] is True + assert evidence["current_frame_excluded_from_plane"] is True + assert evidence["basis"] == "current-lower-cell-observations" + assert evidence["ground_truth"] is False + assert len(evidence["cell_points_xyz_m"]) == detail["prediction"]["cell_count"] + assert len(evidence["cell_signed_residual_m"]) == detail["prediction"]["cell_count"] + assert len(evidence["cell_inlier"]) == detail["prediction"]["cell_count"] + assert sum(evidence["cell_inlier"]) / detail["prediction"]["cell_count"] == ( + detail["prediction"]["inlier_fraction"] + ) assert detail["temporal"]["compared"] is True assert detail["authority"]["commands_enabled"] is False assert review["available"] is True