feat: explain local surface residuals

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 00:36:38 +03:00
parent c6c48fbc38
commit 796b9306f9
10 changed files with 788 additions and 55 deletions
@@ -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}