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,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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user