pointcloud: normalize plane distance by the normal, not the query point - #6325
Conversation
Plane.Distance divided ax+by+cz+d by the length of the query point instead of the length of the plane's normal, so the same offset from a plane measured differently depending on where in space it was. A point 5mm from z=0 reported 5.0 at the origin, 0.05 at x=100, and 0.0035 at (1000,1000). A point at the origin returned NaN. vision/segmentation/plane_segmentation.go already had the correct formula, so the two disagreed. This corrupts ThresholdPointCloudByPlane (points far from the origin are pulled under any threshold) and GetResidual (plane fit quality). SplitPointCloudByPlane is unaffected except at the origin, since it only uses the sign and the query point's norm is positive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| return (p.equation[0]*pt.X + p.equation[1]*pt.Y + p.equation[2]*pt.Z + p.equation[3]) / pt.Norm() | ||
| normalNorm := p.Normal().Norm() | ||
| if normalNorm == 0 { | ||
| // An all-zero equation (NewEmptyPlane) describes no plane. +Inf rather than 0 so that |
There was a problem hiding this comment.
I don't understand this -- is this a problem someone ran into? It's not clear to me that an empty plane having an infinite distance from everything is better than containing nothing?
There was a problem hiding this comment.
The original intent was just to fix the normalization.
is this a problem someone ran into?
No
I would say this is just a matter of convention. Do we want an empty plane to be everywhere (0 distance) or nowhere (infinite distance)? Alternatively we can refactor to Distance() (float64, error) and return an error on empty planes, it would make the handling of those cases more explicit
There was a problem hiding this comment.
Ah, I see now. The diff definitely draws the eyes to the wrong part. I guess this is fine.
| return (p.equation[0]*pt.X + p.equation[1]*pt.Y + p.equation[2]*pt.Z + p.equation[3]) / pt.Norm() | ||
| normalNorm := p.Normal().Norm() | ||
| if normalNorm == 0 { | ||
| // An all-zero equation (NewEmptyPlane) describes no plane. +Inf rather than 0 so that |
There was a problem hiding this comment.
Ah, I see now. The diff definitely draws the eyes to the wrong part. I guess this is fine.
|
Alternative implementation in #6328, which changes the signature to These two PRs are mutually exclusive — both contain the same underlying fix (divide by
#6328's description has a side-by-side trade-off section. |
Summary
pointcloud.Plane.Distancenormalized the plane equation by the length of the query point instead of the length of the plane's normal. The reported distance therefore shrank the further the point was from the origin, and a point at the origin returnedNaN.Found while auditing
pointcloud/after #6314–#6324.The bug
For plane
ax+by+cz+d = 0the point distance is(ax+by+cz+d) / sqrt(a²+b²+c²).Measured against the unpatched code, for the plane
z = 0(unit normal, so the true distance is justz):(0, 0, 5)(100, 0, 5)(1000, 1000, 5)(0, 0, 0)vision/segmentation/plane_segmentation.go:40already contained the correct formula, so the codebase held two implementations of the same equation that disagreed.Impact
ThresholdPointCloudByPlanecompares|dist| <= threshold. A point 500mm from the plane at(1000, 1000, 500)reported0.33, so with a 10mm threshold it was treated as lying on the plane. Points far from the origin are pulled under any threshold; points near it are pushed out.GetResidual(plane-fit quality, drives voxel plane fitting) is scaled per-point by1/|pt|, so the residual is not a fitting error at all.SplitPointCloudByPlaneonly uses the sign and|pt|is positive, so it is unaffected — except at the origin, whereNaNfails both comparisons and the point is silently dropped.Degenerate planes
NewEmptyPlaneproduces an all-zero equation, which previously yielded0/|pt| = 0— i.e. every point read as lying exactly on a plane that does not exist. It now returns+Inf, so threshold and residual callers treat a non-existent plane as containing nothing.TestEmptyPlaneis updated accordingly.Testing
go test ./...across the whole repo passes.golangci-lintclean.Added
TestPlaneDistanceNormalizesByNormal: distance is invariant to the point's position along the plane, is signed, handles the origin, and gives the same answer for2x+2y-2z=0as for the identical planex+y-z=0. Verified to fail against the unpatched source (Expected '1' to almost equal '5').Why this survived
The existing
TestNewPlaneprobes plane{1,1,-1,0}at point(-1,-1,1).|pt| = sqrt(3)and|normal| = sqrt(3)— the two divisors coincide, so that assertion passes under either formula. It is the only distance assertion in the package.Tickets
None
Claude Code Prompts Used
🤖 Generated with Claude Code