feat: explain local surface residuals
This commit is contained in:
parent
c6c48fbc38
commit
796b9306f9
|
|
@ -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
|
triage narrows this to `37` attention frames in `21` episodes and four
|
||||||
high-priority frames. The selected scene, clickable complete-recording timeline
|
high-priority frames. The selected scene, clickable complete-recording timeline
|
||||||
and source-frame review queue are visible in **Парк → Диагностика LiDAR**.
|
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
|
The complete RELLIS-3D v1.1 release is now admitted there and its full
|
||||||
`2,413`-frame validation split is available in **Полигон → Датасеты**. The
|
`2,413`-frame validation split is available in **Полигон → Датасеты**. The
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ export interface LidarLocalSurfaceFrame {
|
||||||
};
|
};
|
||||||
surface: {
|
surface: {
|
||||||
planeCoefficientsMap: [number, number, number, number];
|
planeCoefficientsMap: [number, number, number, number];
|
||||||
|
localRadiusM: number;
|
||||||
sensorHeightM: number;
|
sensorHeightM: number;
|
||||||
slopeDeg: number;
|
slopeDeg: number;
|
||||||
roughnessM: number;
|
roughnessM: number;
|
||||||
|
|
@ -140,6 +141,19 @@ export interface LidarLocalSurfaceFrame {
|
||||||
residualP50M: number;
|
residualP50M: number;
|
||||||
residualP95M: number;
|
residualP95M: number;
|
||||||
inlierFraction: 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: {
|
temporal: {
|
||||||
compared: boolean;
|
compared: boolean;
|
||||||
|
|
@ -670,7 +684,7 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
): LidarLocalSurfaceFrame {
|
): LidarLocalSurfaceFrame {
|
||||||
const source = record(value, "LiDAR local-surface frame");
|
const source = record(value, "LiDAR local-surface frame");
|
||||||
if (
|
if (
|
||||||
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-frame/v1`
|
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-frame/v2`
|
||||||
|| source.access !== "read-only"
|
|| source.access !== "read-only"
|
||||||
|| source.ground_truth !== false
|
|| source.ground_truth !== false
|
||||||
|| source.coordinate_frame !== "map"
|
|| source.coordinate_frame !== "map"
|
||||||
|
|
@ -731,6 +745,7 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
const pose = record(source.pose, "pose");
|
const pose = record(source.pose, "pose");
|
||||||
const surface = record(source.surface, "surface");
|
const surface = record(source.surface, "surface");
|
||||||
const prediction = record(source.prediction, "prediction");
|
const prediction = record(source.prediction, "prediction");
|
||||||
|
const evidence = record(prediction.evidence, "prediction.evidence");
|
||||||
const temporal = record(source.temporal, "temporal");
|
const temporal = record(source.temporal, "temporal");
|
||||||
const counts = record(source.counts, "counts");
|
const counts = record(source.counts, "counts");
|
||||||
const parsedCounts = {
|
const parsedCounts = {
|
||||||
|
|
@ -764,6 +779,12 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
4,
|
4,
|
||||||
"surface.plane_coefficients_map",
|
"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) {
|
if (prediction.current_frame_excluded !== true) {
|
||||||
throw new LidarLocalSurfaceContractError(
|
throw new LidarLocalSurfaceContractError(
|
||||||
"Текущий кадр попал в prediction input",
|
"Текущий кадр попал в prediction input",
|
||||||
|
|
@ -778,6 +799,82 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
"Prediction inlier fraction несовместим",
|
"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 {
|
return {
|
||||||
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
||||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||||
|
|
@ -808,6 +905,7 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
},
|
},
|
||||||
surface: {
|
surface: {
|
||||||
planeCoefficientsMap: [plane[0], plane[1], plane[2], plane[3]],
|
planeCoefficientsMap: [plane[0], plane[1], plane[2], plane[3]],
|
||||||
|
localRadiusM,
|
||||||
sensorHeightM: finite(surface.sensor_height_m, "surface.sensor_height_m"),
|
sensorHeightM: finite(surface.sensor_height_m, "surface.sensor_height_m"),
|
||||||
slopeDeg: finite(surface.slope_deg, "surface.slope_deg"),
|
slopeDeg: finite(surface.slope_deg, "surface.slope_deg"),
|
||||||
roughnessM: finite(surface.roughness_m, "surface.roughness_m"),
|
roughnessM: finite(surface.roughness_m, "surface.roughness_m"),
|
||||||
|
|
@ -836,6 +934,24 @@ export function parseLidarLocalSurfaceFrame(
|
||||||
"prediction.residual_p95_m",
|
"prediction.residual_p95_m",
|
||||||
),
|
),
|
||||||
inlierFraction: predictionInlierFraction,
|
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: {
|
temporal: {
|
||||||
compared: boolean(temporal.compared, "temporal.compared"),
|
compared: boolean(temporal.compared, "temporal.compared"),
|
||||||
|
|
|
||||||
|
|
@ -3108,6 +3108,40 @@
|
||||||
line-height: 1.45;
|
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 {
|
.lidar-local-surface__stage {
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -3189,6 +3223,22 @@
|
||||||
background: #a85061;
|
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 {
|
.lidar-fallback-review {
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ export type LidarGroundViewMode =
|
||||||
| "candidate-disagreement"
|
| "candidate-disagreement"
|
||||||
| "semantic"
|
| "semantic"
|
||||||
| "ground-truth"
|
| "ground-truth"
|
||||||
| "local-surface";
|
| "local-surface"
|
||||||
|
| "prediction-residual";
|
||||||
|
|
||||||
export interface LidarGroundPointCloudFrame {
|
export interface LidarGroundPointCloudFrame {
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
|
|
@ -29,6 +30,12 @@ export interface LidarGroundPointCloudFrame {
|
||||||
localSurfaceClass?: number[];
|
localSurfaceClass?: number[];
|
||||||
localStepCandidate?: number[];
|
localStepCandidate?: number[];
|
||||||
};
|
};
|
||||||
|
predictionEvidence?: {
|
||||||
|
pointsXyzM: Array<[number, number, number]>;
|
||||||
|
signedResidualM: number[];
|
||||||
|
inlierBandM: number;
|
||||||
|
priorPlaneCoefficientsMap: [number, number, number, number];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LidarGroundPointCloudProps {
|
interface LidarGroundPointCloudProps {
|
||||||
|
|
@ -70,7 +77,9 @@ function frameColors(
|
||||||
const current = frame.masks.currentGround[index] === 1;
|
const current = frame.masks.currentGround[index] === 1;
|
||||||
const candidate = frame.masks.candidateGround[index] === 1;
|
const candidate = frame.masks.candidateGround[index] === 1;
|
||||||
const candidateAssigned = frame.masks.candidateAssigned[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 localClass = frame.masks.localSurfaceClass?.[index] ?? 0;
|
||||||
const stepCandidate = frame.masks.localStepCandidate?.[index] === 1;
|
const stepCandidate = frame.masks.localStepCandidate?.[index] === 1;
|
||||||
if (stepCandidate) {
|
if (stepCandidate) {
|
||||||
|
|
@ -162,6 +171,24 @@ function frameColors(
|
||||||
return colors;
|
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({
|
export function LidarGroundPointCloud({
|
||||||
frame,
|
frame,
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -171,6 +198,12 @@ export function LidarGroundPointCloud({
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
|
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||||
const materialRef = useRef<THREE.PointsMaterial | 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 cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||||
const controlsRef = useRef<OrbitControls | null>(null);
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
const fogRef = useRef<THREE.FogExp2 | null>(null);
|
const fogRef = useRef<THREE.FogExp2 | null>(null);
|
||||||
|
|
@ -238,6 +271,47 @@ export function LidarGroundPointCloud({
|
||||||
materialRef.current = material;
|
materialRef.current = material;
|
||||||
scene.add(new THREE.Points(geometry, 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);
|
const grid = new THREE.GridHelper(10, 40, 0x454846, 0x252725);
|
||||||
gridRef.current = grid;
|
gridRef.current = grid;
|
||||||
const gridMaterials = Array.isArray(grid.material)
|
const gridMaterials = Array.isArray(grid.material)
|
||||||
|
|
@ -278,6 +352,10 @@ export function LidarGroundPointCloud({
|
||||||
controls.dispose();
|
controls.dispose();
|
||||||
geometry.dispose();
|
geometry.dispose();
|
||||||
material.dispose();
|
material.dispose();
|
||||||
|
evidenceGeometry.dispose();
|
||||||
|
evidenceMaterial.dispose();
|
||||||
|
priorPlaneGeometry.dispose();
|
||||||
|
priorPlaneMaterial.dispose();
|
||||||
grid.geometry.dispose();
|
grid.geometry.dispose();
|
||||||
gridMaterials.forEach((gridMaterial) => gridMaterial.dispose());
|
gridMaterials.forEach((gridMaterial) => gridMaterial.dispose());
|
||||||
axes.geometry.dispose();
|
axes.geometry.dispose();
|
||||||
|
|
@ -289,6 +367,12 @@ export function LidarGroundPointCloud({
|
||||||
renderer.domElement.remove();
|
renderer.domElement.remove();
|
||||||
geometryRef.current = null;
|
geometryRef.current = null;
|
||||||
materialRef.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;
|
cameraRef.current = null;
|
||||||
controlsRef.current = null;
|
controlsRef.current = null;
|
||||||
fogRef.current = null;
|
fogRef.current = null;
|
||||||
|
|
@ -302,11 +386,24 @@ export function LidarGroundPointCloud({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const geometry = geometryRef.current;
|
const geometry = geometryRef.current;
|
||||||
const material = materialRef.current;
|
const material = materialRef.current;
|
||||||
|
const evidenceGeometry = evidenceGeometryRef.current;
|
||||||
|
const evidenceMaterial = evidenceMaterialRef.current;
|
||||||
|
const priorPlaneGeometry = priorPlaneGeometryRef.current;
|
||||||
const camera = cameraRef.current;
|
const camera = cameraRef.current;
|
||||||
const controls = controlsRef.current;
|
const controls = controlsRef.current;
|
||||||
const fog = fogRef.current;
|
const fog = fogRef.current;
|
||||||
const grid = gridRef.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 positions = new Float32Array(frame.pointCount * 3);
|
||||||
const sampleStep = Math.max(1, Math.floor(frame.pointCount / 5_000));
|
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.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
geometry.computeBoundingSphere();
|
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(
|
const radius = Math.max(
|
||||||
Math.hypot(
|
Math.hypot(
|
||||||
(maximumX - minimumX) / 2,
|
(maximumX - minimumX) / 2,
|
||||||
|
|
@ -351,10 +504,23 @@ export function LidarGroundPointCloud({
|
||||||
);
|
);
|
||||||
viewRadiusRef.current = radius;
|
viewRadiusRef.current = radius;
|
||||||
material.size = THREE.MathUtils.clamp(radius / 155, 0.014, 0.075);
|
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);
|
fog.density = THREE.MathUtils.clamp(0.18 / radius, 0.0008, 0.035);
|
||||||
grid.scale.setScalar(Math.max(radius / 5, 0.25));
|
grid.scale.setScalar(Math.max(radius / 5, 0.25));
|
||||||
|
|
||||||
const targetHeight = coordinateFrame === "sensor-fixed"
|
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(sensorHeightM * 0.35, 0.35)
|
||||||
: Math.max((maximumZ - minimumZ) * 0.42, 0.15);
|
: Math.max((maximumZ - minimumZ) * 0.42, 0.15);
|
||||||
const distance = Math.max(radius * 1.15, 0.9);
|
const distance = Math.max(radius * 1.15, 0.9);
|
||||||
|
|
@ -368,16 +534,42 @@ export function LidarGroundPointCloud({
|
||||||
viewInitializedRef.current = true;
|
viewInitializedRef.current = true;
|
||||||
}
|
}
|
||||||
controls.update();
|
controls.update();
|
||||||
}, [coordinateFrame, frame, sensorHeightM]);
|
}, [coordinateFrame, frame, mode, sensorHeightM]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const geometry = geometryRef.current;
|
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(
|
geometry.setAttribute(
|
||||||
"color",
|
"color",
|
||||||
new THREE.BufferAttribute(frameColors(frame, mode), 3),
|
new THREE.BufferAttribute(frameColors(frame, mode), 3),
|
||||||
);
|
);
|
||||||
geometry.attributes.color.needsUpdate = true;
|
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]);
|
}, [frame, mode]);
|
||||||
|
|
||||||
const resetCamera = () => {
|
const resetCamera = () => {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ export function LidarLocalSurfacePanel({
|
||||||
const [selectedFrameIndex, setSelectedFrameIndex] = useState<number | null>(
|
const [selectedFrameIndex, setSelectedFrameIndex] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [surfaceView, setSurfaceView] = useState<
|
||||||
|
"local-surface" | "prediction-residual"
|
||||||
|
>("local-surface");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -143,10 +146,17 @@ export function LidarLocalSurfacePanel({
|
||||||
|
|
||||||
const cloudFrame = useMemo(() => {
|
const cloudFrame = useMemo(() => {
|
||||||
if (!frame) return null;
|
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 {
|
return {
|
||||||
pointCount: frame.pointCount,
|
pointCount: selectedIndices.length,
|
||||||
pointsXyzM: frame.pointsXyzM,
|
pointsXyzM: selectedIndices.map((index) => frame.pointsXyzM[index]),
|
||||||
intensity0To255: null,
|
intensity0To255: null,
|
||||||
masks: {
|
masks: {
|
||||||
currentGround: emptyMask,
|
currentGround: emptyMask,
|
||||||
|
|
@ -154,9 +164,22 @@ export function LidarLocalSurfacePanel({
|
||||||
candidateGround: emptyMask,
|
candidateGround: emptyMask,
|
||||||
candidateAssigned: emptyMask,
|
candidateAssigned: emptyMask,
|
||||||
disagreement: emptyMask,
|
disagreement: emptyMask,
|
||||||
localSurfaceClass: frame.pointClass,
|
localSurfaceClass: selectedIndices.map(
|
||||||
localStepCandidate: frame.pointStepCandidate,
|
(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]);
|
}, [frame]);
|
||||||
const selectedReviewItem = useMemo(
|
const selectedReviewItem = useMemo(
|
||||||
|
|
@ -165,6 +188,16 @@ export function LidarLocalSurfacePanel({
|
||||||
) ?? null,
|
) ?? null,
|
||||||
[review, selectedFrameIndex],
|
[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) {
|
if (!model && !loading && !error) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -266,9 +299,38 @@ export function LidarLocalSurfacePanel({
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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">
|
<div className="lidar-local-surface__stage">
|
||||||
{cloudFrame && frame ? (
|
{cloudFrame && frame ? (
|
||||||
<LidarGroundPointCloud frame={cloudFrame} mode="local-surface" />
|
<LidarGroundPointCloud frame={cloudFrame} mode={surfaceView} />
|
||||||
) : (
|
) : (
|
||||||
<div className="lidar-ground-scene-placeholder">
|
<div className="lidar-ground-scene-placeholder">
|
||||||
<StatusBadge tone={error ? "danger" : "accent"}>
|
<StatusBadge tone={error ? "danger" : "accent"}>
|
||||||
|
|
@ -285,6 +347,10 @@ export function LidarLocalSurfacePanel({
|
||||||
<small>t = {formatNumber(frame.sessionSeconds)} с</small>
|
<small>t = {formatNumber(frame.sessionSeconds)} с</small>
|
||||||
</div>
|
</div>
|
||||||
<dl>
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Локальный радиус</dt>
|
||||||
|
<dd>{formatNumber(frame.surface.localRadiusM, 1)} м</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Высота</dt>
|
<dt>Высота</dt>
|
||||||
<dd>{formatNumber(frame.surface.sensorHeightM)} м</dd>
|
<dd>{formatNumber(frame.surface.sensorHeightM)} м</dd>
|
||||||
|
|
@ -333,16 +399,28 @@ export function LidarLocalSurfacePanel({
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="lidar-local-surface__legend">
|
<div className="lidar-local-surface__legend">
|
||||||
|
{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="step" />Перепад / бордюр-кандидат</span>
|
||||||
<span><i data-class="surface" />Наблюдаемая поверхность</span>
|
<span><i data-class="surface" />Наблюдаемая поверхность</span>
|
||||||
<span><i data-class="occupied" />Выше поверхности</span>
|
<span><i data-class="occupied" />Выше поверхности</span>
|
||||||
<span><i data-class="below" />Нижний выброс</span>
|
<span><i data-class="below" />Нижний выброс</span>
|
||||||
<span><i data-class="unknown" />Не классифицировано</span>
|
<span><i data-class="unknown" />Не классифицировано</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Жёлтый слой — геометрический кандидат, не распознанный бордюр и
|
{surfaceView === "prediction-residual"
|
||||||
не ground truth. Пустота остаётся unknown; движение этим слоем
|
? "Полупрозрачная плоскость построена только по предыдущему TTL-окну. Крупные точки — нижние cell-наблюдения текущего кадра: оранжевое выше, пурпурное ниже; это объяснение residual, не разметка препятствий."
|
||||||
не разрешается.
|
: "Жёлтый слой — геометрический кандидат, не распознанный бордюр и не ground truth. Пустота остаётся unknown; движение этим слоем не разрешается."}
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ function catalog(overrides = {}) {
|
||||||
|
|
||||||
function frame(overrides = {}) {
|
function frame(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
schema_version: "missioncore.k1-local-surface-frame/v1",
|
schema_version: "missioncore.k1-local-surface-frame/v2",
|
||||||
model_id: modelId,
|
model_id: modelId,
|
||||||
source_pack_id: sourcePackId,
|
source_pack_id: sourcePackId,
|
||||||
session_id: "20260720T065719Z_viewer_live",
|
session_id: "20260720T065719Z_viewer_live",
|
||||||
|
|
@ -161,6 +161,7 @@ function frame(overrides = {}) {
|
||||||
},
|
},
|
||||||
surface: {
|
surface: {
|
||||||
plane_coefficients_map: [0, 0, 1, 0],
|
plane_coefficients_map: [0, 0, 1, 0],
|
||||||
|
local_radius_m: 10,
|
||||||
sensor_height_m: 1.3,
|
sensor_height_m: 1.3,
|
||||||
slope_deg: 0,
|
slope_deg: 0,
|
||||||
roughness_m: 0.02,
|
roughness_m: 0.02,
|
||||||
|
|
@ -179,10 +180,23 @@ function frame(overrides = {}) {
|
||||||
prediction: {
|
prediction: {
|
||||||
available: true,
|
available: true,
|
||||||
current_frame_excluded: true,
|
current_frame_excluded: true,
|
||||||
cell_count: 32,
|
cell_count: 2,
|
||||||
residual_p50_m: 0.04,
|
residual_p50_m: 0.04,
|
||||||
residual_p95_m: 0.2,
|
residual_p95_m: 0.2,
|
||||||
inlier_fraction: 0.95,
|
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: {
|
temporal: {
|
||||||
compared: true,
|
compared: true,
|
||||||
|
|
@ -374,6 +388,9 @@ test("decodes passive local-surface evidence", () => {
|
||||||
assert.equal(decodedFrame.counts.occupied, 1);
|
assert.equal(decodedFrame.counts.occupied, 1);
|
||||||
assert.equal(decodedFrame.counts.stepCandidate, 1);
|
assert.equal(decodedFrame.counts.stepCandidate, 1);
|
||||||
assert.equal(decodedFrame.prediction.currentFrameExcluded, true);
|
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);
|
assert.equal(decodedFrame.surface.sensorHeightM, 1.3);
|
||||||
|
|
||||||
const decodedTimeline = parseLidarLocalSurfaceTimeline(timeline());
|
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 () => {
|
test("fetches the complete local-surface timeline read-only", async () => {
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const decoded = await fetchLidarLocalSurfaceTimeline(modelId, {
|
const decoded = await fetchLidarLocalSurfaceTimeline(modelId, {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# LiDAR worker: product value, evidence boundary and implementation roadmap
|
# 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
|
Status: accepted architecture plan; L0/L1 implemented; L2 diagnostic A/B
|
||||||
complete; full GOOSE and RELLIS qualification complete; L2.6c K1 replay
|
complete; full GOOSE and RELLIS qualification complete; L2.6d K1 replay
|
||||||
local-surface temporal qualification and operator triage implemented;
|
local-surface temporal qualification, operator triage and prior-plane residual
|
||||||
residual explainability and live shadow next
|
explainability implemented; bounded live shadow next
|
||||||
Scope: passively received real-time K1 point/pose evidence, immutable replay and
|
Scope: passively received real-time K1 point/pose evidence, immutable replay and
|
||||||
future live shadow processing
|
future live shadow processing
|
||||||
Explicitly out of scope: K1 firmware modification, a new onboard exporter, new
|
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
|
- [x] Publish a deterministic, read-only operator queue for temporal
|
||||||
transitions and heavy prediction tails, with source-frame navigation and no
|
transitions and heavy prediction tails, with source-frame navigation and no
|
||||||
safety authority.
|
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
|
evidence so a selected tail can be explained spatially instead of only by an
|
||||||
aggregate p95.
|
aggregate p95.
|
||||||
- [ ] Complete the remaining qualification report with per-frame latency,
|
- [ ] 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
|
surface remains stable. This separates a local prediction-tail problem from a
|
||||||
global plane-transition problem. The recording has no independent ground truth
|
global plane-transition problem. The recording has no independent ground truth
|
||||||
for either episode, so the UI calls them review evidence rather than algorithm
|
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
|
failures.
|
||||||
cell/point residuals before changing fit thresholds or entering live shadow.
|
|
||||||
|
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
|
Exit: one immutable K1 session yields both a persistent reconstruction and a
|
||||||
bounded local world state without hard-coded terrain height or scanner-side
|
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`
|
interface. Leave-current-frame-out temporal qualification now covers `525`
|
||||||
samples: the median surface error is stable, while the p95 tail remains too
|
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
|
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
|
inspection set to four high-priority frames in two episodes. Prior-plane
|
||||||
immediate work is prior-plane residual explainability for those episodes, then
|
residual explainability now shows that episode `09` is a localized positive
|
||||||
a bounded live-shadow queue and separate dynamic-observation layer. Nvblox,
|
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
|
raw-scan detectors and alternative SLAM remain optional later gates because the
|
||||||
current report contract does not carry their required ray/timing semantics.
|
current report contract does not carry their required ray/timing semantics.
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,10 @@ Implemented now:
|
||||||
- deterministic `missioncore.k1-local-surface-review/v1` replay triage:
|
- deterministic `missioncore.k1-local-surface-review/v1` replay triage:
|
||||||
`37` attention frames grouped into `21` episodes, with four
|
`37` attention frames grouped into `21` episodes, with four
|
||||||
high-priority frames and direct source-frame navigation;
|
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
|
- a provider-neutral read-only local-surface view in
|
||||||
**Парк → Диагностика LiDAR**, synchronized to the five existing
|
**Парк → Диагностика LiDAR**, synchronized to the five existing
|
||||||
RAVNOVES00 scene selectors and a clickable complete-recording timeline;
|
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
|
- no RELLIS ROS bag admission, continuous synchronized playback or production
|
||||||
promotion;
|
promotion;
|
||||||
- no ray-cleared free-space or planner-authoritative rolling occupancy map.
|
- no ray-cleared free-space or planner-authoritative rolling occupancy map.
|
||||||
- no point/cell-aligned prior-plane residual overlay yet; aggregate tail
|
- no bounded live-shadow execution of the accepted K1 local-surface profile
|
||||||
evidence is not enough to identify its physical cause.
|
yet; the residual overlay remains replay-only and non-authoritative.
|
||||||
|
|
||||||
## Product surface boundary
|
## Product surface boundary
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from .lidar_field_review import (
|
||||||
|
|
||||||
K1_LOCAL_SURFACE_SCHEMA: Final = "missioncore.k1-local-surface/v1"
|
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_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_TIMELINE_SCHEMA: Final = "missioncore.k1-local-surface-timeline/v1"
|
||||||
K1_LOCAL_SURFACE_REVIEW_SCHEMA: Final = "missioncore.k1-local-surface-review/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_ARRAYS_NAME: Final = "local-surface.npz"
|
||||||
|
|
@ -180,6 +180,26 @@ class K1LocalSurfaceProfile:
|
||||||
DEFAULT_K1_LOCAL_SURFACE_PROFILE: Final = 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:
|
class K1LocalSurfaceV1:
|
||||||
"""Strict reader for source-aligned, passive K1 local-surface evidence."""
|
"""Strict reader for source-aligned, passive K1 local-surface evidence."""
|
||||||
|
|
||||||
|
|
@ -253,10 +273,21 @@ class K1LocalSurfaceV1:
|
||||||
"step_candidate_point_count",
|
"step_candidate_point_count",
|
||||||
"point_step_candidate",
|
"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)
|
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")
|
raise LidarGroundError("K1 local-surface arrays are incomplete")
|
||||||
self.has_temporal_qualification = qualification <= files
|
self.has_temporal_qualification = qualification <= files
|
||||||
|
self.has_prediction_evidence = prediction_evidence <= files
|
||||||
vector_f64 = (
|
vector_f64 = (
|
||||||
"sensor_height_m",
|
"sensor_height_m",
|
||||||
"slope_deg",
|
"slope_deg",
|
||||||
|
|
@ -368,6 +399,63 @@ class K1LocalSurfaceV1:
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
raise LidarGroundError("K1 local-surface step candidates are invalid")
|
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"]
|
valid = self.arrays["frame_valid"]
|
||||||
if (
|
if (
|
||||||
np.any(self.arrays["frame_failure_code"][valid] != FRAME_VALID)
|
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]
|
step_candidate = self.arrays["point_step_candidate"][start:end]
|
||||||
else:
|
else:
|
||||||
step_candidate = np.zeros(end - start, dtype=np.uint8)
|
step_candidate = np.zeros(end - start, dtype=np.uint8)
|
||||||
|
prediction_evidence = self._prediction_evidence_detail(frame_index)
|
||||||
counts = {
|
counts = {
|
||||||
"classified": int(np.count_nonzero(point_class)),
|
"classified": int(np.count_nonzero(point_class)),
|
||||||
"surface": int(np.count_nonzero(point_class == POINT_SURFACE)),
|
"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]
|
"plane_coefficients_map": self.arrays["plane_coefficients_map"][frame_index]
|
||||||
.astype(np.float64)
|
.astype(np.float64)
|
||||||
.tolist(),
|
.tolist(),
|
||||||
|
"local_radius_m": self._local_radius_m(),
|
||||||
"sensor_height_m": float(self.arrays["sensor_height_m"][frame_index]),
|
"sensor_height_m": float(self.arrays["sensor_height_m"][frame_index]),
|
||||||
"slope_deg": float(self.arrays["slope_deg"][frame_index]),
|
"slope_deg": float(self.arrays["slope_deg"][frame_index]),
|
||||||
"roughness_m": float(self.arrays["roughness_m"][frame_index]),
|
"roughness_m": float(self.arrays["roughness_m"][frame_index]),
|
||||||
|
|
@ -535,6 +625,7 @@ class K1LocalSurfaceV1:
|
||||||
if self.has_temporal_qualification
|
if self.has_temporal_qualification
|
||||||
else 0.0
|
else 0.0
|
||||||
),
|
),
|
||||||
|
"evidence": prediction_evidence,
|
||||||
},
|
},
|
||||||
"temporal": {
|
"temporal": {
|
||||||
"compared": (
|
"compared": (
|
||||||
|
|
@ -580,6 +671,51 @@ class K1LocalSurfaceV1:
|
||||||
"authority": self.report["authority"],
|
"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]:
|
def timeline_detail(self, source: E10LidarFieldSource) -> dict[str, object]:
|
||||||
_validate_source_binding(self, source)
|
_validate_source_binding(self, source)
|
||||||
frame_count = source.frame_count
|
frame_count = source.frame_count
|
||||||
|
|
@ -825,6 +961,28 @@ class K1LocalSurfaceV1:
|
||||||
"episode_max_frame_gap": 2,
|
"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(
|
def build_k1_local_surface(
|
||||||
source: E10LidarFieldSource,
|
source: E10LidarFieldSource,
|
||||||
|
|
@ -850,6 +1008,12 @@ def build_k1_local_surface(
|
||||||
pose_delta = np.abs(source_arrays["pose_point_delta_ms"])
|
pose_delta = np.abs(source_arrays["pose_point_delta_ms"])
|
||||||
cache: dict[tuple[int, int], tuple[float, float]] = {}
|
cache: dict[tuple[int, int], tuple[float, float]] = {}
|
||||||
previous_surface: tuple[float, float, float, float] | None = None
|
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):
|
for frame_index in range(frame_count):
|
||||||
start = int(offsets[frame_index])
|
start = int(offsets[frame_index])
|
||||||
|
|
@ -883,12 +1047,24 @@ def build_k1_local_surface(
|
||||||
profile,
|
profile,
|
||||||
)
|
)
|
||||||
if prediction is not None:
|
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_available"][frame_index] = True
|
||||||
arrays["prediction_cell_count"][frame_index] = prediction_cells
|
arrays["prediction_cell_count"][frame_index] = (
|
||||||
arrays["prediction_residual_p50_m"][frame_index] = residual_p50
|
prediction.cell_points.shape[0]
|
||||||
arrays["prediction_residual_p95_m"][frame_index] = residual_p95
|
)
|
||||||
arrays["prediction_inlier_fraction"][frame_index] = inlier_fraction
|
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)
|
_update_cache(cache, local_cloud, session_seconds, profile)
|
||||||
cell_keys, cell_points, cell_times = _local_cache_records(
|
cell_keys, cell_points, cell_times = _local_cache_records(
|
||||||
cache,
|
cache,
|
||||||
|
|
@ -987,6 +1163,12 @@ def build_k1_local_surface(
|
||||||
np.count_nonzero(step_candidate)
|
np.count_nonzero(step_candidate)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
arrays.update(
|
||||||
|
_prediction_evidence_arrays(
|
||||||
|
prediction_cell_points,
|
||||||
|
prediction_cell_residuals,
|
||||||
|
)
|
||||||
|
)
|
||||||
logical_content_sha256 = _logical_sha256(arrays)
|
logical_content_sha256 = _logical_sha256(arrays)
|
||||||
valid = arrays["frame_valid"]
|
valid = arrays["frame_valid"]
|
||||||
identity = {
|
identity = {
|
||||||
|
|
@ -1145,8 +1327,8 @@ def build_k1_local_surface(
|
||||||
"status": "replay-experiment-only",
|
"status": "replay-experiment-only",
|
||||||
"production_promotion": False,
|
"production_promotion": False,
|
||||||
"next_gate": (
|
"next_gate": (
|
||||||
"review the full recording, qualify local-surface stability, then run "
|
"run the accepted profile through a bounded latest-wins live-shadow "
|
||||||
"bounded latest-wins live shadow without commands"
|
"queue without commands, free-space or safety authority"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"authority": {
|
"authority": {
|
||||||
|
|
@ -1230,6 +1412,10 @@ def _empty_arrays(
|
||||||
"prediction_residual_p50_m": np.zeros(frame_count, dtype="<f8"),
|
"prediction_residual_p50_m": np.zeros(frame_count, dtype="<f8"),
|
||||||
"prediction_residual_p95_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_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"),
|
"height_delta_m": np.zeros(frame_count, dtype="<f8"),
|
||||||
"slope_delta_deg": np.zeros(frame_count, dtype="<f8"),
|
"slope_delta_deg": np.zeros(frame_count, dtype="<f8"),
|
||||||
"roughness_delta_m": 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(
|
def _update_cache(
|
||||||
cache: dict[tuple[int, int], tuple[float, float]],
|
cache: dict[tuple[int, int], tuple[float, float]],
|
||||||
cloud: npt.NDArray[np.float64],
|
cloud: npt.NDArray[np.float64],
|
||||||
|
|
@ -1411,7 +1634,7 @@ def _prediction_metrics(
|
||||||
current_cell_points: npt.NDArray[np.float64],
|
current_cell_points: npt.NDArray[np.float64],
|
||||||
position: npt.NDArray[np.float64],
|
position: npt.NDArray[np.float64],
|
||||||
profile: K1LocalSurfaceProfile,
|
profile: K1LocalSurfaceProfile,
|
||||||
) -> tuple[float, float, float, int] | None:
|
) -> _PredictionEvidence | None:
|
||||||
"""Score current lower-cell evidence against a plane built without that frame."""
|
"""Score current lower-cell evidence against a plane built without that frame."""
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -1429,16 +1652,17 @@ def _prediction_metrics(
|
||||||
evaluation = current_cell_points[:, 2] <= cutoff
|
evaluation = current_cell_points[:, 2] <= cutoff
|
||||||
if int(np.count_nonzero(evaluation)) < profile.minimum_surface_cells:
|
if int(np.count_nonzero(evaluation)) < profile.minimum_surface_cells:
|
||||||
return None
|
return None
|
||||||
residual = np.abs(
|
evaluation_points = current_cell_points[evaluation]
|
||||||
_height_above_plane(current_cell_points[evaluation], prior_plane)
|
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 None
|
||||||
return (
|
return _PredictionEvidence(
|
||||||
float(np.percentile(residual, 50)),
|
prior_plane=prior_plane,
|
||||||
float(np.percentile(residual, 95)),
|
cell_points=evaluation_points,
|
||||||
float(np.mean(residual <= profile.surface_band_m)),
|
signed_residuals=signed_residuals,
|
||||||
int(residual.shape[0]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]["is_ground_truth"] is False
|
||||||
assert temporal["step_candidates"]["frames_with_candidates"] > 0
|
assert temporal["step_candidates"]["frames_with_candidates"] > 0
|
||||||
assert detail["valid"] is True
|
assert detail["valid"] is True
|
||||||
|
assert detail["surface"]["local_radius_m"] == 5.0
|
||||||
assert 1.2 < detail["surface"]["sensor_height_m"] < 1.6
|
assert 1.2 < detail["surface"]["sensor_height_m"] < 1.6
|
||||||
assert detail["counts"]["surface"] > 50
|
assert detail["counts"]["surface"] > 50
|
||||||
assert detail["counts"]["occupied"] > 0
|
assert detail["counts"]["occupied"] > 0
|
||||||
assert detail["counts"]["step_candidate"] > 0
|
assert detail["counts"]["step_candidate"] > 0
|
||||||
assert detail["prediction"]["current_frame_excluded"] is True
|
assert detail["prediction"]["current_frame_excluded"] is True
|
||||||
assert detail["prediction"]["available"] 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["temporal"]["compared"] is True
|
||||||
assert detail["authority"]["commands_enabled"] is False
|
assert detail["authority"]["commands_enabled"] is False
|
||||||
assert review["available"] is True
|
assert review["available"] is True
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue