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:
2026-06-25 08:22:20 +00:00
parent cec0766e67
commit 29f1d45d66
266 changed files with 1311475 additions and 2846 deletions

View File

@@ -0,0 +1,29 @@
"""Composable point cloud denoising pipeline.
Each step is a pure function ``points -> points``. Steps are configured
via YAML and chained by :func:`run_pipeline`.
Available steps (matching ``denoise.steps[].type``):
- ``passthrough`` — keep points inside an axis-aligned bounding box
- ``voxel`` — voxel-grid downsample
- ``statistical`` — statistical outlier removal (SOR)
- ``radius`` — radius outlier removal (ROR)
- ``smoothing`` — MLS smoothing (only when explicitly enabled)
"""
from .passthrough import passthrough_filter
from .voxel import voxel_downsample
from .statistical import statistical_outlier_removal
from .radius import radius_outlier_removal
from .smoothing import mls_smoothing
from .pipeline import run_pipeline
__all__ = [
"passthrough_filter",
"voxel_downsample",
"statistical_outlier_removal",
"radius_outlier_removal",
"mls_smoothing",
"run_pipeline",
]

View File

@@ -0,0 +1,41 @@
"""Passthrough (axis-aligned bounding-box) filter."""
from __future__ import annotations
import numpy as np
def passthrough_filter(
points: np.ndarray,
*,
bbox: tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None = None,
bbox_padding_mm: float | None = None,
) -> np.ndarray:
"""Keep only points inside ``bbox``.
``bbox`` is ``((xmin, xmax), (ymin, ymax), (zmin, zmax))``.
If ``bbox`` is ``None`` and ``bbox_padding_mm`` is given, the bbox is
derived from the cloud itself, padded by ``bbox_padding_mm`` on each
side — which is a useful no-op default that nonetheless drops far-out
NaNs / spurious points.
"""
pts = np.asarray(points, dtype=np.float64)
if bbox is None:
if bbox_padding_mm is None:
return pts.copy()
pad = float(bbox_padding_mm)
lo = pts.min(axis=0) - pad
hi = pts.max(axis=0) + pad
bbox = ((lo[0], hi[0]), (lo[1], hi[1]), (lo[2], hi[2]))
(xlo, xhi), (ylo, yhi), (zlo, zhi) = bbox
mask = (
(pts[:, 0] >= xlo) & (pts[:, 0] <= xhi)
& (pts[:, 1] >= ylo) & (pts[:, 1] <= yhi)
& (pts[:, 2] >= zlo) & (pts[:, 2] <= zhi)
)
return pts[mask]
__all__ = ["passthrough_filter"]

View File

@@ -0,0 +1,71 @@
"""Compose denoising steps from a YAML-style step list.
Example config::
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 30
std_ratio: 2.0
"""
from __future__ import annotations
from typing import Any, Callable
import numpy as np
from .passthrough import passthrough_filter
from .radius import radius_outlier_removal
from .smoothing import mls_smoothing
from .statistical import statistical_outlier_removal
from .voxel import voxel_downsample
_STEP_FNS: dict[str, Callable[..., np.ndarray]] = {
"passthrough": passthrough_filter,
"voxel": voxel_downsample,
"statistical": statistical_outlier_removal,
"radius": radius_outlier_removal,
"smoothing": mls_smoothing,
}
def run_pipeline(
points: np.ndarray,
steps: list[dict[str, Any]] | None,
*,
on_step: Callable[[str, np.ndarray], None] | None = None,
) -> tuple[np.ndarray, list[dict[str, Any]]]:
"""Apply each step in order and return the final cloud + a per-step log.
Each log entry is::
{"type": ..., "params": {...}, "n_in": int, "n_out": int}
"""
pts = np.asarray(points, dtype=np.float64)
log: list[dict[str, Any]] = []
if not steps:
return pts.copy(), log
for raw in steps:
cfg = dict(raw)
kind = cfg.pop("type")
if kind not in _STEP_FNS:
raise ValueError(f"unknown denoise step '{kind}'; valid: {sorted(_STEP_FNS)}")
fn = _STEP_FNS[kind]
n_in = pts.shape[0]
pts = fn(pts, **cfg)
log.append(
{"type": kind, "params": cfg, "n_in": n_in, "n_out": int(pts.shape[0])}
)
if on_step is not None:
on_step(kind, pts)
return pts, log
__all__ = ["run_pipeline"]

View File

@@ -0,0 +1,38 @@
"""Radius outlier removal (ROR).
A point is kept iff at least ``nb_points`` other points lie within
``radius_mm``. Useful for getting rid of spray noise in scanner data.
"""
from __future__ import annotations
import numpy as np
def radius_outlier_removal(
points: np.ndarray,
*,
nb_points: int = 8,
radius_mm: float = 2.0,
) -> np.ndarray:
pts = np.asarray(points, dtype=np.float64)
n = pts.shape[0]
if n == 0:
return pts.copy()
try:
from scipy.spatial import cKDTree
tree = cKDTree(pts)
counts = tree.query_ball_point(pts, r=float(radius_mm), return_length=True)
# `counts` includes the point itself; threshold accordingly.
keep = counts >= int(nb_points) + 1
except ImportError:
diff = pts[:, None, :] - pts[None, :, :]
d = np.sqrt(np.sum(diff * diff, axis=2))
keep = (d <= float(radius_mm)).sum(axis=1) >= int(nb_points) + 1
return pts[keep]
__all__ = ["radius_outlier_removal"]

View File

@@ -0,0 +1,84 @@
"""Optional moving-least-squares (MLS) smoothing.
For each point, fit a local polynomial in the neighbour set's tangent
plane and project the point onto it. Implementation here is a degree-2
local fit using only numpy + scipy.cKDTree. For most coupler-panel work
this stage is *off* by default — leave it disabled unless you actually
need smoothing in addition to SOR/ROR.
"""
from __future__ import annotations
import numpy as np
def mls_smoothing(
points: np.ndarray,
*,
search_radius_mm: float = 2.0,
polynomial_order: int = 2,
min_neighbors: int = 8,
) -> np.ndarray:
pts = np.asarray(points, dtype=np.float64)
n = pts.shape[0]
if n == 0:
return pts.copy()
try:
from scipy.spatial import cKDTree
except ImportError:
# Without a tree this is hopelessly slow; skip safely.
return pts.copy()
tree = cKDTree(pts)
out = pts.copy()
for i in range(n):
idx = tree.query_ball_point(pts[i], r=float(search_radius_mm))
if len(idx) < min_neighbors:
continue
nbh = pts[idx]
center = nbh.mean(axis=0)
# Local frame from PCA on the neighbourhood.
Q = nbh - center
cov = (Q.T @ Q) / max(len(idx) - 1, 1)
w, V = np.linalg.eigh(cov)
# Smallest eigenvector ≈ surface normal; the other two span the tangent.
normal = V[:, 0]
tangents = V[:, 1:]
local_xy = Q @ tangents # (k, 2)
local_z = Q @ normal # (k,)
if polynomial_order == 1:
A = np.column_stack([np.ones(len(idx)), local_xy])
else:
x, y = local_xy[:, 0], local_xy[:, 1]
A = np.column_stack([np.ones(len(idx)), x, y, x * x, x * y, y * y])
try:
coef, *_ = np.linalg.lstsq(A, local_z, rcond=None)
except np.linalg.LinAlgError:
continue
# Project pts[i] onto the surface in local frame.
rel = pts[i] - center
xy_i = np.array([rel @ tangents[:, 0], rel @ tangents[:, 1]])
if polynomial_order == 1:
z_i = coef[0] + coef[1] * xy_i[0] + coef[2] * xy_i[1]
else:
z_i = (
coef[0]
+ coef[1] * xy_i[0]
+ coef[2] * xy_i[1]
+ coef[3] * xy_i[0] ** 2
+ coef[4] * xy_i[0] * xy_i[1]
+ coef[5] * xy_i[1] ** 2
)
out[i] = center + tangents[:, 0] * xy_i[0] + tangents[:, 1] * xy_i[1] + normal * z_i
return out
__all__ = ["mls_smoothing"]

View File

@@ -0,0 +1,44 @@
"""Statistical outlier removal (SOR).
For each point, compute the mean distance to its ``k`` nearest neighbours.
A point whose mean distance exceeds the global mean by ``std_ratio`` ×
the global std is dropped.
"""
from __future__ import annotations
import numpy as np
def statistical_outlier_removal(
points: np.ndarray,
*,
nb_neighbors: int = 30,
std_ratio: float = 2.0,
) -> np.ndarray:
pts = np.asarray(points, dtype=np.float64)
n = pts.shape[0]
if n <= nb_neighbors + 1:
return pts.copy()
try:
from scipy.spatial import cKDTree
tree = cKDTree(pts)
dist, _ = tree.query(pts, k=nb_neighbors + 1)
# First column is the point itself (distance 0).
mean_d = dist[:, 1:].mean(axis=1)
except ImportError:
# Plain numpy fallback (O(N²); fine for N up to a few thousand).
diff = pts[:, None, :] - pts[None, :, :]
d = np.sqrt(np.sum(diff * diff, axis=2))
d.sort(axis=1)
mean_d = d[:, 1 : nb_neighbors + 1].mean(axis=1)
mu = mean_d.mean()
sigma = mean_d.std()
keep = mean_d <= mu + float(std_ratio) * sigma
return pts[keep]
__all__ = ["statistical_outlier_removal"]

View File

@@ -0,0 +1,37 @@
"""Voxel-grid downsampling."""
from __future__ import annotations
import numpy as np
def voxel_downsample(points: np.ndarray, *, voxel_mm: float) -> np.ndarray:
"""Replace each occupied voxel with the centroid of its points."""
if voxel_mm <= 0:
raise ValueError("voxel_mm must be > 0")
pts = np.asarray(points, dtype=np.float64)
if pts.shape[0] == 0:
return pts.copy()
keys = np.floor(pts / float(voxel_mm)).astype(np.int64)
# Compose a single 1-D key for fast unique() grouping.
kmin = keys.min(axis=0)
keys = keys - kmin
span = keys.max(axis=0) + 1
flat = keys[:, 0] + span[0] * (keys[:, 1] + span[1] * keys[:, 2])
order = np.argsort(flat, kind="stable")
flat_sorted = flat[order]
pts_sorted = pts[order]
# Group by run-length on the sorted flat keys.
boundaries = np.concatenate(([0], np.where(np.diff(flat_sorted) != 0)[0] + 1, [pts.shape[0]]))
n_groups = len(boundaries) - 1
out = np.empty((n_groups, 3), dtype=np.float64)
for i in range(n_groups):
s, e = boundaries[i], boundaries[i + 1]
out[i] = pts_sorted[s:e].mean(axis=0)
return out
__all__ = ["voxel_downsample"]