feat: 升级算法包 delivery_v1.1 → v1.2,新增任务级 CAD 与测距面板

- 算法包替换为 delivery_v1.2(coupler_panel 新增 autotune/layer_select,underframe_deflection 新增 single_beam 管线),删除 delivery_v1.1
- manifests/adapters 的 PYTHONPATH 与版本说明同步更新到 v1.2
- 新增任务级 CAD 文件支持(task_cad_files 表、上传/读取路由、TaskManager 全选与加载显示)
- 新增 train-measurement 自定义前端面板(YAML 编辑器 + 可视化 ROI 配置 + 3D 取点)
- PointCloudViewer 支持 PLY 叠加层渲染与 ROI 拾取
- Dockerfile 改用 Miniforge 安装 pythonocc-core,修复 libgomp/libstdc++ 依赖
This commit is contained in:
2026-06-11 02:30:04 +00:00
parent 16811100d1
commit cec0766e67
143 changed files with 3087 additions and 12604 deletions

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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"]