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,61 @@
|
||||
"""Mesh-to-point-cloud sampling strategies.
|
||||
|
||||
Unified entry point::
|
||||
|
||||
from coupler_panel.sampling import sample_mesh
|
||||
pts = sample_mesh(mesh, method="poisson_disk", num_points=100_000)
|
||||
|
||||
Each strategy lives in its own module; the dispatcher just routes by name.
|
||||
``mesh`` must expose ``vertices: (V, 3)`` and ``faces: (F, 3)`` as ndarrays
|
||||
(such as our :class:`coupler_panel.cad.mesh.TriangleMesh`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .uniform_area import sample_uniform_area
|
||||
from .poisson_disk import sample_poisson_disk
|
||||
from .adaptive import sample_adaptive
|
||||
from .grid_spacing import sample_grid_spacing, num_points_from_spacing
|
||||
|
||||
|
||||
_DISPATCH = {
|
||||
"uniform_area": sample_uniform_area,
|
||||
"poisson_disk": sample_poisson_disk,
|
||||
"adaptive": sample_adaptive,
|
||||
"grid_spacing": sample_grid_spacing,
|
||||
}
|
||||
|
||||
|
||||
def sample_mesh(mesh, method: str, **kwargs) -> np.ndarray:
|
||||
"""Dispatch to the named strategy. ``**kwargs`` are passed through."""
|
||||
if method not in _DISPATCH:
|
||||
raise ValueError(
|
||||
f"unknown sampling method '{method}'; valid: {sorted(_DISPATCH)}"
|
||||
)
|
||||
return _DISPATCH[method](mesh, **kwargs)
|
||||
|
||||
|
||||
def resolve_num_points(mesh, *, num_points: int | None, spacing_mm: float | None) -> int:
|
||||
"""If ``spacing_mm`` is given, convert it to a point count using the
|
||||
mesh surface area; otherwise return ``num_points``.
|
||||
"""
|
||||
if spacing_mm is not None and spacing_mm > 0:
|
||||
return num_points_from_spacing(mesh, spacing_mm)
|
||||
if num_points is None:
|
||||
raise ValueError("either num_points or spacing_mm must be set")
|
||||
return int(num_points)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"sample_mesh",
|
||||
"sample_uniform_area",
|
||||
"sample_poisson_disk",
|
||||
"sample_adaptive",
|
||||
"sample_grid_spacing",
|
||||
"resolve_num_points",
|
||||
"num_points_from_spacing",
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Curvature-adaptive sampling.
|
||||
|
||||
Idea: weight every triangle by ``area * (1 + k * curvature_proxy)``, then
|
||||
do area-weighted sampling on the *weighted* areas. The curvature proxy is
|
||||
``1 - mean_dot(face_normal, neighbour_face_normals)``, normalised to
|
||||
[0, 1] across the mesh.
|
||||
|
||||
This produces denser samples on rounded edges, hole boundaries, and small
|
||||
features without needing a real curvature operator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .uniform_area import _triangle_areas, sample_uniform_area
|
||||
|
||||
|
||||
def _face_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
|
||||
a = vertices[faces[:, 0]]
|
||||
b = vertices[faces[:, 1]]
|
||||
c = vertices[faces[:, 2]]
|
||||
n = np.cross(b - a, c - a)
|
||||
norms = np.linalg.norm(n, axis=1, keepdims=True)
|
||||
norms[norms < 1e-12] = 1.0
|
||||
return n / norms
|
||||
|
||||
|
||||
def _vertex_face_adjacency(faces: np.ndarray, n_vertices: int) -> list[list[int]]:
|
||||
adj: list[list[int]] = [[] for _ in range(n_vertices)]
|
||||
for i, f in enumerate(faces):
|
||||
adj[f[0]].append(i)
|
||||
adj[f[1]].append(i)
|
||||
adj[f[2]].append(i)
|
||||
return adj
|
||||
|
||||
|
||||
def _curvature_proxy(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
|
||||
"""Per-face roughness in [0, 1]."""
|
||||
fn = _face_normals(vertices, faces)
|
||||
adj = _vertex_face_adjacency(faces, vertices.shape[0])
|
||||
proxy = np.zeros(faces.shape[0], dtype=np.float64)
|
||||
for i, f in enumerate(faces):
|
||||
neighbours: set[int] = set()
|
||||
for v in f:
|
||||
neighbours.update(adj[v])
|
||||
neighbours.discard(i)
|
||||
if not neighbours:
|
||||
continue
|
||||
nb = fn[list(neighbours)]
|
||||
d = np.clip(nb @ fn[i], -1.0, 1.0)
|
||||
proxy[i] = 1.0 - np.mean(d)
|
||||
if proxy.max() > 0:
|
||||
proxy = proxy / proxy.max()
|
||||
return proxy
|
||||
|
||||
|
||||
def sample_adaptive(
|
||||
mesh,
|
||||
*,
|
||||
num_points: int,
|
||||
seed: int | None = 42,
|
||||
curvature_weight: float = 1.0,
|
||||
**_: Any,
|
||||
) -> np.ndarray:
|
||||
"""Curvature-weighted sampling."""
|
||||
if num_points <= 0:
|
||||
return np.empty((0, 3), dtype=np.float64)
|
||||
|
||||
v = np.asarray(mesh.vertices, dtype=np.float64)
|
||||
f = np.asarray(mesh.faces, dtype=np.int64)
|
||||
if f.shape[0] == 0:
|
||||
return np.empty((0, 3), dtype=np.float64)
|
||||
|
||||
# On large meshes the O(F·avg_degree) curvature pass is expensive; if we
|
||||
# are above ~200k faces, fall back to plain uniform_area, which is the
|
||||
# natural definition when curvature_weight = 0.
|
||||
if f.shape[0] > 200_000:
|
||||
return sample_uniform_area(mesh, num_points=num_points, seed=seed)
|
||||
|
||||
areas = _triangle_areas(v, f)
|
||||
proxy = _curvature_proxy(v, f)
|
||||
weights = areas * (1.0 + float(curvature_weight) * proxy)
|
||||
total = weights.sum()
|
||||
if total <= 0:
|
||||
return sample_uniform_area(mesh, num_points=num_points, seed=seed)
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
probs = weights / total
|
||||
tri_idx = rng.choice(f.shape[0], size=num_points, p=probs)
|
||||
u = rng.random(num_points)
|
||||
w = rng.random(num_points)
|
||||
flip = u + w > 1.0
|
||||
u[flip] = 1.0 - u[flip]
|
||||
w[flip] = 1.0 - w[flip]
|
||||
a = v[f[tri_idx, 0]]
|
||||
b = v[f[tri_idx, 1]]
|
||||
c = v[f[tri_idx, 2]]
|
||||
return a + (b - a) * u[:, None] + (c - a) * w[:, None]
|
||||
|
||||
|
||||
__all__ = ["sample_adaptive"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Spacing-driven sampling.
|
||||
|
||||
Translates a target inter-point spacing in mm into a point count, then
|
||||
defers to area-weighted sampling. Useful when you want to sweep over
|
||||
``spacing_mm ∈ {0.25, 0.5, 1, 2, 5}`` rather than over absolute counts.
|
||||
|
||||
For an isotropic surface sample, the mean nearest-neighbour distance is
|
||||
roughly ``sqrt(area / n)`` (square cells). We invert that:
|
||||
|
||||
n ≈ area / spacing²
|
||||
|
||||
and add a small fudge factor of 1.15 to compensate for sample variance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .uniform_area import _triangle_areas, sample_uniform_area
|
||||
|
||||
|
||||
def num_points_from_spacing(mesh, spacing_mm: float, *, fudge: float = 1.15) -> int:
|
||||
if spacing_mm <= 0:
|
||||
raise ValueError("spacing_mm must be > 0")
|
||||
v = np.asarray(mesh.vertices, dtype=np.float64)
|
||||
f = np.asarray(mesh.faces, dtype=np.int64)
|
||||
total_area = float(_triangle_areas(v, f).sum())
|
||||
n = int(round(fudge * total_area / (spacing_mm ** 2)))
|
||||
return max(n, 1)
|
||||
|
||||
|
||||
def sample_grid_spacing(
|
||||
mesh,
|
||||
*,
|
||||
spacing_mm: float | None = None,
|
||||
num_points: int | None = None,
|
||||
seed: int | None = 42,
|
||||
fudge: float = 1.15,
|
||||
**_: Any,
|
||||
) -> np.ndarray:
|
||||
"""If ``spacing_mm`` is given, derive ``num_points`` from it.
|
||||
|
||||
Falls through to area-weighted uniform sampling.
|
||||
"""
|
||||
if spacing_mm is not None:
|
||||
n = num_points_from_spacing(mesh, float(spacing_mm), fudge=fudge)
|
||||
elif num_points is not None:
|
||||
n = int(num_points)
|
||||
else:
|
||||
raise ValueError("either spacing_mm or num_points must be set")
|
||||
return sample_uniform_area(mesh, num_points=n, seed=seed)
|
||||
|
||||
|
||||
__all__ = ["sample_grid_spacing", "num_points_from_spacing"]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Poisson-disk surface sampling.
|
||||
|
||||
Uses ``open3d.geometry.TriangleMesh.sample_points_poisson_disk`` when
|
||||
open3d is available; falls back to area-weighted uniform sampling
|
||||
followed by an O(N²) Bridson-style culling on small meshes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .uniform_area import sample_uniform_area
|
||||
|
||||
|
||||
def _try_open3d():
|
||||
try:
|
||||
import open3d as o3d # type: ignore
|
||||
|
||||
return o3d
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
def sample_poisson_disk(
|
||||
mesh,
|
||||
*,
|
||||
num_points: int,
|
||||
seed: int | None = 42,
|
||||
init_factor: int = 5,
|
||||
**_: Any,
|
||||
) -> np.ndarray:
|
||||
"""Poisson-disk samples on a triangle mesh."""
|
||||
if num_points <= 0:
|
||||
return np.empty((0, 3), dtype=np.float64)
|
||||
|
||||
o3d = _try_open3d()
|
||||
if o3d is not None:
|
||||
# open3d wants its own mesh class.
|
||||
m = o3d.geometry.TriangleMesh()
|
||||
m.vertices = o3d.utility.Vector3dVector(np.asarray(mesh.vertices, dtype=np.float64))
|
||||
m.triangles = o3d.utility.Vector3iVector(np.asarray(mesh.faces, dtype=np.int32))
|
||||
m.compute_vertex_normals()
|
||||
# Open3D's API takes seed via 'seed' kwarg (0.18+) — passing it best-effort.
|
||||
try:
|
||||
pcd = m.sample_points_poisson_disk(
|
||||
number_of_points=int(num_points),
|
||||
init_factor=int(init_factor),
|
||||
seed=int(seed) if seed is not None else -1,
|
||||
)
|
||||
except TypeError:
|
||||
pcd = m.sample_points_poisson_disk(
|
||||
number_of_points=int(num_points),
|
||||
init_factor=int(init_factor),
|
||||
)
|
||||
return np.asarray(pcd.points, dtype=np.float64)
|
||||
|
||||
# ---- Fallback: oversample + grid cull ---------------------------------
|
||||
over = sample_uniform_area(
|
||||
mesh,
|
||||
num_points=num_points * max(init_factor, 5),
|
||||
seed=seed,
|
||||
)
|
||||
# Estimate target spacing from mesh bbox.
|
||||
bbox = over.max(axis=0) - over.min(axis=0)
|
||||
if num_points <= 1:
|
||||
return over[:num_points]
|
||||
target_spacing = float(np.cbrt(np.prod(bbox)) / max(num_points, 1) ** (1 / 3))
|
||||
return _grid_cull(over, target_spacing, num_points, seed)
|
||||
|
||||
|
||||
def _grid_cull(
|
||||
points: np.ndarray,
|
||||
spacing: float,
|
||||
keep_n: int,
|
||||
seed: int | None,
|
||||
) -> np.ndarray:
|
||||
"""Pick at most one point per voxel of size ``spacing``."""
|
||||
rng = np.random.default_rng(seed)
|
||||
perm = rng.permutation(points.shape[0])
|
||||
voxel = np.floor(points[perm] / max(spacing, 1e-6)).astype(np.int64)
|
||||
seen: set[tuple[int, int, int]] = set()
|
||||
out_idx: list[int] = []
|
||||
for k, key in enumerate(map(tuple, voxel)):
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out_idx.append(perm[k])
|
||||
if len(out_idx) >= keep_n:
|
||||
break
|
||||
return points[np.asarray(out_idx)]
|
||||
|
||||
|
||||
__all__ = ["sample_poisson_disk"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Area-weighted uniform triangle sampling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _triangle_areas(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
|
||||
a = vertices[faces[:, 0]]
|
||||
b = vertices[faces[:, 1]]
|
||||
c = vertices[faces[:, 2]]
|
||||
return 0.5 * np.linalg.norm(np.cross(b - a, c - a), axis=1)
|
||||
|
||||
|
||||
def sample_uniform_area(
|
||||
mesh,
|
||||
*,
|
||||
num_points: int,
|
||||
seed: int | None = 42,
|
||||
**_: Any,
|
||||
) -> np.ndarray:
|
||||
"""Sample ``num_points`` from a triangle mesh, weighted by area.
|
||||
|
||||
Returns an ``(N, 3) float64`` ndarray.
|
||||
"""
|
||||
if num_points <= 0:
|
||||
return np.empty((0, 3), dtype=np.float64)
|
||||
|
||||
v = np.asarray(mesh.vertices, dtype=np.float64)
|
||||
f = np.asarray(mesh.faces, dtype=np.int64)
|
||||
areas = _triangle_areas(v, f)
|
||||
total = float(areas.sum())
|
||||
if total <= 0:
|
||||
raise ValueError("mesh has zero total area")
|
||||
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
probs = areas / total
|
||||
tri_idx = rng.choice(len(f), size=num_points, p=probs)
|
||||
u = rng.random(num_points)
|
||||
w = rng.random(num_points)
|
||||
flip = u + w > 1.0
|
||||
u[flip] = 1.0 - u[flip]
|
||||
w[flip] = 1.0 - w[flip]
|
||||
a = v[f[tri_idx, 0]]
|
||||
b = v[f[tri_idx, 1]]
|
||||
c = v[f[tri_idx, 2]]
|
||||
return a + (b - a) * u[:, None] + (c - a) * w[:, None]
|
||||
|
||||
|
||||
__all__ = ["sample_uniform_area"]
|
||||
Reference in New Issue
Block a user