feat: explain local surface residuals

This commit is contained in:
DCCONSTRUCTIONS 2026-07-26 00:36:38 +03:00
parent c6c48fbc38
commit 796b9306f9
10 changed files with 788 additions and 55 deletions

View File

@ -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

View File

@ -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"),

View File

@ -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;

View File

@ -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<HTMLDivElement | null>(null);
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
const materialRef = useRef<THREE.PointsMaterial | null>(null);
const evidenceGeometryRef = useRef<THREE.BufferGeometry | null>(null);
const evidenceMaterialRef = useRef<THREE.PointsMaterial | null>(null);
const evidencePointsRef = useRef<THREE.Points | null>(null);
const priorPlaneGeometryRef = useRef<THREE.BufferGeometry | null>(null);
const priorPlaneMaterialRef = useRef<THREE.MeshBasicMaterial | null>(null);
const priorPlaneMeshRef = useRef<THREE.Mesh | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const fogRef = useRef<THREE.FogExp2 | null>(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 = () => {

View File

@ -42,6 +42,9 @@ export function LidarLocalSurfacePanel({
const [selectedFrameIndex, setSelectedFrameIndex] = useState<number | null>(
null,
);
const [surfaceView, setSurfaceView] = useState<
"local-surface" | "prediction-residual"
>("local-surface");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -143,10 +146,17 @@ export function LidarLocalSurfacePanel({
const cloudFrame = useMemo(() => {
if (!frame) return null;
const emptyMask = new Array<number>(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<number>(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 ? (
<div
className="lidar-local-surface__view-switch"
role="group"
aria-label="Режим локальной поверхности"
>
<button
type="button"
aria-pressed={surfaceView === "local-surface"}
onClick={() => setSurfaceView("local-surface")}
>
Классы поверхности
</button>
<button
type="button"
aria-pressed={surfaceView === "prediction-residual"}
disabled={!frame.prediction.evidence.available}
onClick={() => setSurfaceView("prediction-residual")}
>
Residual к prior-plane
</button>
<span>
{frame.prediction.evidence.available
? `${frame.prediction.cellCount.toLocaleString("ru-RU")} проверенных ячеек`
: "Для этого кадра нет prior-plane"}
</span>
</div>
) : null}
<div className="lidar-local-surface__stage">
{cloudFrame && frame ? (
<LidarGroundPointCloud frame={cloudFrame} mode="local-surface" />
<LidarGroundPointCloud frame={cloudFrame} mode={surfaceView} />
) : (
<div className="lidar-ground-scene-placeholder">
<StatusBadge tone={error ? "danger" : "accent"}>
@ -285,6 +347,10 @@ export function LidarLocalSurfacePanel({
<small>t = {formatNumber(frame.sessionSeconds)} с</small>
</div>
<dl>
<div>
<dt>Локальный радиус</dt>
<dd>{formatNumber(frame.surface.localRadiusM, 1)} м</dd>
</div>
<div>
<dt>Высота</dt>
<dd>{formatNumber(frame.surface.sensorHeightM)} м</dd>
@ -333,16 +399,28 @@ export function LidarLocalSurfacePanel({
</div>
</dl>
<div className="lidar-local-surface__legend">
<span><i data-class="step" />Перепад / бордюр-кандидат</span>
<span><i data-class="surface" />Наблюдаемая поверхность</span>
<span><i data-class="occupied" />Выше поверхности</span>
<span><i data-class="below" />Нижний выброс</span>
<span><i data-class="unknown" />Не классифицировано</span>
{surfaceView === "prediction-residual" ? (
<>
<span><i data-class="prior-plane" />Prior-plane</span>
<span><i data-class="residual-inlier" />В prior-band</span>
<span><i data-class="residual-above" />Выше prior-plane</span>
<span><i data-class="residual-below" />Ниже prior-plane</span>
<span><i data-class="unknown" />Контекст кадра</span>
</>
) : (
<>
<span><i data-class="step" />Перепад / бордюр-кандидат</span>
<span><i data-class="surface" />Наблюдаемая поверхность</span>
<span><i data-class="occupied" />Выше поверхности</span>
<span><i data-class="below" />Нижний выброс</span>
<span><i data-class="unknown" />Не классифицировано</span>
</>
)}
</div>
<p>
Жёлтый слой геометрический кандидат, не распознанный бордюр и
не ground truth. Пустота остаётся unknown; движение этим слоем
не разрешается.
{surfaceView === "prediction-residual"
? "Полупрозрачная плоскость построена только по предыдущему TTL-окну. Крупные точки — нижние cell-наблюдения текущего кадра: оранжевое выше, пурпурное ниже; это объяснение residual, не разметка препятствий."
: "Жёлтый слой — геометрический кандидат, не распознанный бордюр и не ground truth. Пустота остаётся unknown; движение этим слоем не разрешается."}
</p>
</aside>
) : null}

View File

@ -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, {

View File

@ -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 `12531254` 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.

View File

@ -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

View File

@ -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("<i8")
or int(offsets[0]) != 0
or np.any(np.diff(offsets) < 0)
or points.ndim != 2
or points.shape[1:] != (3,)
or points.dtype != np.dtype("<f8")
or residuals.shape != (points.shape[0],)
or residuals.dtype != np.dtype("<f8")
or int(offsets[-1]) != points.shape[0]
or planes.shape != (frame_count, 4)
or planes.dtype != np.dtype("<f8")
or not np.isfinite(points).all()
or not np.isfinite(residuals).all()
or not np.isfinite(planes).all()
or np.any(
np.diff(offsets)
!= self.arrays["prediction_cell_count"]
)
or np.any(
np.diff(offsets)[~self.arrays["prediction_available"]] != 0
)
):
raise LidarGroundError(
"K1 local-surface prediction evidence is invalid"
)
surface_band_m = self._surface_band_m()
for frame_index in np.flatnonzero(
self.arrays["prediction_available"]
):
start = int(offsets[frame_index])
end = int(offsets[frame_index + 1])
absolute = np.abs(residuals[start:end])
if (
not np.isclose(
np.percentile(absolute, 50),
self.arrays["prediction_residual_p50_m"][frame_index],
)
or not np.isclose(
np.percentile(absolute, 95),
self.arrays["prediction_residual_p95_m"][frame_index],
)
or not np.isclose(
np.mean(absolute <= surface_band_m),
self.arrays["prediction_inlier_fraction"][frame_index],
)
):
raise LidarGroundError(
"K1 local-surface prediction evidence is inconsistent"
)
valid = self.arrays["frame_valid"]
if (
np.any(self.arrays["frame_failure_code"][valid] != FRAME_VALID)
@ -437,6 +525,7 @@ class K1LocalSurfaceV1:
step_candidate = self.arrays["point_step_candidate"][start:end]
else:
step_candidate = np.zeros(end - start, dtype=np.uint8)
prediction_evidence = self._prediction_evidence_detail(frame_index)
counts = {
"classified": int(np.count_nonzero(point_class)),
"surface": int(np.count_nonzero(point_class == POINT_SURFACE)),
@ -495,6 +584,7 @@ class K1LocalSurfaceV1:
"plane_coefficients_map": self.arrays["plane_coefficients_map"][frame_index]
.astype(np.float64)
.tolist(),
"local_radius_m": self._local_radius_m(),
"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]),
@ -535,6 +625,7 @@ class K1LocalSurfaceV1:
if self.has_temporal_qualification
else 0.0
),
"evidence": prediction_evidence,
},
"temporal": {
"compared": (
@ -580,6 +671,51 @@ class K1LocalSurfaceV1:
"authority": self.report["authority"],
}
def _prediction_evidence_detail(
self,
frame_index: int,
) -> 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="<f8"),
"prediction_residual_p95_m": np.zeros(frame_count, dtype="<f8"),
"prediction_inlier_fraction": np.zeros(frame_count, dtype="<f8"),
"prediction_prior_plane_coefficients_map": np.zeros(
(frame_count, 4),
dtype="<f8",
),
"height_delta_m": np.zeros(frame_count, dtype="<f8"),
"slope_delta_deg": np.zeros(frame_count, dtype="<f8"),
"roughness_delta_m": np.zeros(frame_count, dtype="<f8"),
@ -1243,6 +1429,43 @@ def _empty_arrays(
}
def _prediction_evidence_arrays(
cell_points: list[npt.NDArray[np.float64]],
signed_residuals: list[npt.NDArray[np.float64]],
) -> 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="<i8")
for index, (points, residuals) in enumerate(
zip(cell_points, signed_residuals, strict=True)
):
if (
points.ndim != 2
or points.shape[1:] != (3,)
or residuals.shape != (points.shape[0],)
or not np.isfinite(points).all()
or not np.isfinite(residuals).all()
):
raise LidarGroundError(
"K1 local-surface prediction evidence is invalid"
)
offsets[index + 1] = offsets[index] + points.shape[0]
if int(offsets[-1]) == 0:
joined_points = np.empty((0, 3), dtype="<f8")
joined_residuals = np.empty(0, dtype="<f8")
else:
joined_points = np.concatenate(cell_points).astype("<f8", copy=False)
joined_residuals = np.concatenate(signed_residuals).astype(
"<f8",
copy=False,
)
return {
"prediction_cell_offsets": offsets,
"prediction_cell_points_map": joined_points,
"prediction_cell_signed_residual_m": joined_residuals,
}
def _update_cache(
cache: dict[tuple[int, int], tuple[float, float]],
cloud: npt.NDArray[np.float64],
@ -1411,7 +1634,7 @@ def _prediction_metrics(
current_cell_points: npt.NDArray[np.float64],
position: npt.NDArray[np.float64],
profile: K1LocalSurfaceProfile,
) -> 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,
)

View File

@ -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