feat: 算法升级、整车配准集成与性能分析文档
算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3 适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新 后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动 前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复 文档: 新增5份算法性能分析报告
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""Geometry construction & plane fitting for the coupler panel."""
|
||||
|
||||
from .reference import build_reference_frame
|
||||
from .roi import extract_panel_roi
|
||||
from .plane_fit import fit_panel_plane
|
||||
|
||||
__all__ = [
|
||||
"build_reference_frame",
|
||||
"extract_panel_roi",
|
||||
"fit_panel_plane",
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Robust panel-plane fitting.
|
||||
|
||||
Pipeline:
|
||||
|
||||
1. RANSAC: pick 3 random points, build a plane, count inliers within
|
||||
``inlier_thresh_mm``. Repeat ``ransac_iterations`` times; keep the
|
||||
best inlier set.
|
||||
2. SVD: refit the plane on the inliers (eigenvector with the smallest
|
||||
eigenvalue of the centred covariance).
|
||||
3. Sign convention: enforce ``n_x > 0``.
|
||||
|
||||
Returns a :class:`PanelPlane`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..exceptions import PlaneFitError
|
||||
from ..structures import PanelPlane
|
||||
|
||||
|
||||
def _plane_from_three(p1, p2, p3) -> tuple[np.ndarray, float] | None:
|
||||
n = np.cross(p2 - p1, p3 - p1)
|
||||
norm = np.linalg.norm(n)
|
||||
if norm < 1e-9:
|
||||
return None
|
||||
n = n / norm
|
||||
d = -float(np.dot(n, p1))
|
||||
return n, d
|
||||
|
||||
|
||||
def _svd_plane(points: np.ndarray) -> tuple[np.ndarray, float]:
|
||||
centroid = points.mean(axis=0)
|
||||
Q = points - centroid
|
||||
cov = (Q.T @ Q) / max(points.shape[0] - 1, 1)
|
||||
w, V = np.linalg.eigh(cov)
|
||||
n = V[:, 0]
|
||||
n = n / max(np.linalg.norm(n), 1e-12)
|
||||
d = -float(np.dot(n, centroid))
|
||||
return n, d
|
||||
|
||||
|
||||
def fit_panel_plane(
|
||||
points: np.ndarray,
|
||||
*,
|
||||
plane_fit_cfg: dict[str, Any],
|
||||
seed: int | None = 7,
|
||||
) -> PanelPlane:
|
||||
pts = np.asarray(points, dtype=np.float64)
|
||||
n_pts = pts.shape[0]
|
||||
if n_pts < 3:
|
||||
raise PlaneFitError(f"need ≥ 3 points, got {n_pts}")
|
||||
|
||||
iterations = int(plane_fit_cfg.get("ransac_iterations", 200))
|
||||
thresh = plane_fit_cfg.get("ransac_inlier_thresh_mm", 1.5)
|
||||
if thresh in (None, "auto"):
|
||||
thresh = 1.5
|
||||
thresh = float(thresh)
|
||||
min_inlier_ratio = float(plane_fit_cfg.get("min_inlier_ratio", 0.6))
|
||||
enforce_nx_positive = bool(plane_fit_cfg.get("enforce_nx_positive", True))
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
best_inliers: np.ndarray | None = None
|
||||
best_count = -1
|
||||
|
||||
for _ in range(iterations):
|
||||
sample = rng.choice(n_pts, size=3, replace=False)
|
||||
plane = _plane_from_three(pts[sample[0]], pts[sample[1]], pts[sample[2]])
|
||||
if plane is None:
|
||||
continue
|
||||
n, d = plane
|
||||
dist = np.abs(pts @ n + d)
|
||||
inliers = dist <= thresh
|
||||
c = int(inliers.sum())
|
||||
if c > best_count:
|
||||
best_count = c
|
||||
best_inliers = inliers
|
||||
|
||||
if best_inliers is None or best_count < 3:
|
||||
raise PlaneFitError(
|
||||
f"RANSAC found {best_count} inliers in {iterations} iters at "
|
||||
f"thresh={thresh} mm; check noise level / threshold"
|
||||
)
|
||||
|
||||
inlier_ratio = best_count / n_pts
|
||||
inlier_pts = pts[best_inliers]
|
||||
n, d = _svd_plane(inlier_pts)
|
||||
if enforce_nx_positive and n[0] < 0:
|
||||
n = -n
|
||||
d = -d
|
||||
|
||||
residuals = inlier_pts @ n + d
|
||||
rms = float(np.sqrt(np.mean(residuals ** 2)))
|
||||
|
||||
if inlier_ratio < min_inlier_ratio:
|
||||
# Caller will log a quality flag; we still return the result.
|
||||
pass
|
||||
|
||||
centroid = inlier_pts.mean(axis=0)
|
||||
return PanelPlane(
|
||||
normal=tuple(n.tolist()),
|
||||
point_on_plane=tuple(centroid.tolist()),
|
||||
d_offset=float(d),
|
||||
inlier_ratio=float(inlier_ratio),
|
||||
inlier_residual_rms_mm=rms,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["fit_panel_plane"]
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Construct the reference frame from four hole centres.
|
||||
|
||||
Given the four locating-hole centres :math:`H_1 .. H_4`:
|
||||
|
||||
- Panel centre :math:`C` = mean of the four centres (= diagonal
|
||||
intersection for a rectangle).
|
||||
- Reference line :math:`L = C + \\lambda \\hat e_x`.
|
||||
- Reference plane: :math:`z = C_z`.
|
||||
- Longitudinal mid-plane: :math:`y = C_y`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..structures import HoleSet, ReferenceFrame
|
||||
|
||||
|
||||
def build_reference_frame(holes: HoleSet) -> ReferenceFrame:
|
||||
centers = holes.locating_centers()
|
||||
if centers.shape != (4, 3):
|
||||
raise ValueError(f"need 4 locating-hole centres, got shape {centers.shape}")
|
||||
C = centers.mean(axis=0)
|
||||
return ReferenceFrame(
|
||||
center_C=tuple(C.tolist()),
|
||||
line_direction=(1.0, 0.0, 0.0),
|
||||
reference_plane_z=float(C[2]),
|
||||
longitudinal_mid_y=float(C[1]),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_reference_frame"]
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Extract the *clean* panel ROI from the registered point cloud.
|
||||
|
||||
Strategy:
|
||||
|
||||
1. Take points whose 2D projection falls inside the four-hole rectangle
|
||||
(shrunk inwards by ``edge_buffer_mm``).
|
||||
2. Drop points within ``hole_dilate_mm`` of any locating-hole centre
|
||||
(radius + dilation).
|
||||
3. Drop points within ``central_hole_dilate_mm`` of the central hole.
|
||||
4. Drop chamfer points (per-point normal deviates from panel normal by
|
||||
more than ``acos(chamfer_normal_dot_min)``). Normals are estimated
|
||||
on the fly via PCA on each point's k-neighbourhood.
|
||||
5. Drop z-score outliers along the panel normal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..structures import HoleSet
|
||||
|
||||
|
||||
def _kth_normals(points: np.ndarray, k: int = 20) -> np.ndarray:
|
||||
"""Per-point normal via PCA on the k-NN. Returns ``(N, 3)``."""
|
||||
n = points.shape[0]
|
||||
if n < k + 1:
|
||||
return np.tile(np.array([1.0, 0.0, 0.0]), (n, 1))
|
||||
try:
|
||||
from scipy.spatial import cKDTree
|
||||
except ImportError:
|
||||
return np.tile(np.array([1.0, 0.0, 0.0]), (n, 1))
|
||||
|
||||
tree = cKDTree(points)
|
||||
_, idx = tree.query(points, k=k + 1)
|
||||
normals = np.empty((n, 3), dtype=np.float64)
|
||||
for i in range(n):
|
||||
nbh = points[idx[i, 1:]]
|
||||
c = nbh.mean(axis=0)
|
||||
Q = nbh - c
|
||||
cov = (Q.T @ Q) / max(k - 1, 1)
|
||||
try:
|
||||
w, V = np.linalg.eigh(cov)
|
||||
normals[i] = V[:, 0]
|
||||
except np.linalg.LinAlgError:
|
||||
normals[i] = np.array([1.0, 0.0, 0.0])
|
||||
return normals
|
||||
|
||||
|
||||
def _signed_to_plane(points: np.ndarray, normal: np.ndarray, offset: float) -> np.ndarray:
|
||||
"""Signed distance from each point to plane n·x + d = 0."""
|
||||
return points @ normal + offset
|
||||
|
||||
|
||||
def extract_panel_roi(
|
||||
points: np.ndarray,
|
||||
*,
|
||||
holes: HoleSet,
|
||||
panel_normal_seed: np.ndarray,
|
||||
roi_cfg: dict[str, Any],
|
||||
) -> tuple[np.ndarray, dict[str, Any]]:
|
||||
"""Return (ROI points, diagnostics dict).
|
||||
|
||||
``holes`` and ``panel_normal_seed`` are expected in the *same* frame
|
||||
as ``points`` — registration must already have happened before this
|
||||
is called.
|
||||
"""
|
||||
pts = np.asarray(points, dtype=np.float64)
|
||||
if pts.shape[0] == 0:
|
||||
return pts.copy(), {"n_in": 0, "n_out": 0, "stages": []}
|
||||
|
||||
n = np.asarray(panel_normal_seed, dtype=np.float64)
|
||||
n = n / max(np.linalg.norm(n), 1e-12)
|
||||
if n[0] < 0:
|
||||
n = -n
|
||||
|
||||
centers_3d = holes.locating_centers() # (4, 3)
|
||||
C = centers_3d.mean(axis=0)
|
||||
|
||||
# Build local 2D frame to test "inside the rectangle".
|
||||
y_world = np.array([0.0, 1.0, 0.0])
|
||||
u = y_world - np.dot(y_world, n) * n
|
||||
if np.linalg.norm(u) < 1e-6:
|
||||
u = np.array([0.0, 0.0, 1.0]) - np.dot(np.array([0.0, 0.0, 1.0]), n) * n
|
||||
u /= np.linalg.norm(u)
|
||||
v = np.cross(n, u)
|
||||
v /= np.linalg.norm(v)
|
||||
|
||||
rel = pts - C
|
||||
uv = np.column_stack([rel @ u, rel @ v])
|
||||
centers_uv = np.column_stack([(centers_3d - C) @ u, (centers_3d - C) @ v])
|
||||
|
||||
edge_buf = float(roi_cfg.get("edge_buffer_mm", 5.0))
|
||||
u_lo = centers_uv[:, 0].min() + edge_buf
|
||||
u_hi = centers_uv[:, 0].max() - edge_buf
|
||||
v_lo = centers_uv[:, 1].min() + edge_buf
|
||||
v_hi = centers_uv[:, 1].max() - edge_buf
|
||||
|
||||
stages: list[dict[str, Any]] = []
|
||||
n_in = pts.shape[0]
|
||||
|
||||
mask = (uv[:, 0] >= u_lo) & (uv[:, 0] <= u_hi) & (uv[:, 1] >= v_lo) & (uv[:, 1] <= v_hi)
|
||||
stages.append({"name": "rectangle", "n_kept": int(mask.sum())})
|
||||
|
||||
# 2. Locating hole exclusion (use 3D distance to each hole's centre,
|
||||
# with the hole's own radius + dilation).
|
||||
hole_dilate = float(roi_cfg.get("hole_dilate_mm", 3.0))
|
||||
for h in holes.locating:
|
||||
center = np.asarray(h.center)
|
||||
r = float(h.radius_mm) + hole_dilate
|
||||
d2 = np.sum((pts - center) ** 2, axis=1)
|
||||
mask &= d2 > r * r
|
||||
stages.append({"name": "locating_holes", "n_kept": int(mask.sum())})
|
||||
|
||||
# 3. Central hole exclusion.
|
||||
if holes.central is not None:
|
||||
center = np.asarray(holes.central.center)
|
||||
r = float(holes.central.radius_mm) + float(roi_cfg.get("central_hole_dilate_mm", 5.0))
|
||||
d2 = np.sum((pts - center) ** 2, axis=1)
|
||||
mask &= d2 > r * r
|
||||
stages.append({"name": "central_hole", "n_kept": int(mask.sum())})
|
||||
|
||||
# 4. Chamfer/normal filter — only run if we still have enough points to PCA.
|
||||
chamfer_dot_min = float(roi_cfg.get("chamfer_normal_dot_min", 0.85))
|
||||
if mask.sum() > 50 and chamfer_dot_min > 0:
|
||||
kept = pts[mask]
|
||||
normals = _kth_normals(kept, k=20)
|
||||
# Align all normals so they point along +n_panel (sign-invariant).
|
||||
flips = (normals @ n) < 0
|
||||
normals[flips] = -normals[flips]
|
||||
keep_local = (normals @ n) >= chamfer_dot_min
|
||||
# Map back into the full mask.
|
||||
idxs = np.where(mask)[0]
|
||||
new_mask = np.zeros_like(mask)
|
||||
new_mask[idxs[keep_local]] = True
|
||||
mask = new_mask
|
||||
stages.append({"name": "chamfer_normal", "n_kept": int(mask.sum())})
|
||||
|
||||
# 5. Z-score outlier rejection along panel normal.
|
||||
z_score = float(roi_cfg.get("outlier_z_score", 3.0))
|
||||
if mask.sum() > 50 and z_score > 0:
|
||||
kept = pts[mask]
|
||||
d = kept @ n
|
||||
mu = d.mean()
|
||||
sigma = d.std()
|
||||
if sigma > 1e-12:
|
||||
keep_local = np.abs(d - mu) <= z_score * sigma
|
||||
idxs = np.where(mask)[0]
|
||||
new_mask = np.zeros_like(mask)
|
||||
new_mask[idxs[keep_local]] = True
|
||||
mask = new_mask
|
||||
stages.append({"name": "z_score", "n_kept": int(mask.sum())})
|
||||
|
||||
out = pts[mask]
|
||||
return out, {"n_in": int(n_in), "n_out": int(out.shape[0]), "stages": stages}
|
||||
|
||||
|
||||
__all__ = ["extract_panel_roi"]
|
||||
Reference in New Issue
Block a user