feat: explain local surface residuals
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user