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:
39
alg/平整度、平面度、轮廓测量v1.5/preprocessing/curvature.py
Normal file
39
alg/平整度、平面度、轮廓测量v1.5/preprocessing/curvature.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
|
||||
|
||||
class CurvatureEstimator:
|
||||
"""Estimate local point-cloud curvature using neighborhood covariance."""
|
||||
|
||||
def compute(self, pointcloud: o3d.geometry.PointCloud, radius: float = 2.0, max_nn: int = 30) -> np.ndarray:
|
||||
pointcloud.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=max_nn))
|
||||
points = np.asarray(pointcloud.points)
|
||||
kdtree = o3d.geometry.KDTreeFlann(pointcloud)
|
||||
curvatures = np.zeros(len(points), dtype=np.float64)
|
||||
|
||||
for i, point in enumerate(points):
|
||||
_, indices, _ = kdtree.search_hybrid_vector_3d(point, radius, max_nn)
|
||||
if len(indices) < 3:
|
||||
curvatures[i] = 1.0
|
||||
continue
|
||||
|
||||
neighbors = points[indices]
|
||||
cov = np.cov((neighbors - neighbors.mean(axis=0)).T)
|
||||
eigenvalues = np.linalg.eigvalsh(cov)
|
||||
denom = eigenvalues.sum()
|
||||
curvatures[i] = float(eigenvalues[0] / denom) if denom > 0 else 0.0
|
||||
|
||||
return curvatures
|
||||
|
||||
def colorize(self, pointcloud: o3d.geometry.PointCloud, curvatures: np.ndarray, color_scale: float = 3.0) -> o3d.geometry.PointCloud:
|
||||
pcd = copy.deepcopy(pointcloud)
|
||||
normalized = curvatures / (np.max(curvatures) + 1e-12)
|
||||
normalized = np.clip(normalized * color_scale, 0, 1)
|
||||
colors = np.zeros((len(normalized), 3))
|
||||
colors[:, 0] = normalized
|
||||
colors[:, 1] = 1 - normalized
|
||||
pcd.colors = o3d.utility.Vector3dVector(colors)
|
||||
return pcd
|
||||
190
alg/平整度、平面度、轮廓测量v1.5/preprocessing/filters.py
Normal file
190
alg/平整度、平面度、轮廓测量v1.5/preprocessing/filters.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
from scipy.spatial import KDTree
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterResult:
|
||||
name: str
|
||||
pointcloud: o3d.geometry.PointCloud
|
||||
metrics: dict[str, Any]
|
||||
outliers: o3d.geometry.PointCloud | None = None
|
||||
|
||||
|
||||
def _base_metrics(original_count: int, filtered_count: int, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
removed_count = original_count - filtered_count
|
||||
return {
|
||||
"method": method,
|
||||
"original_points": original_count,
|
||||
"filtered_points": filtered_count,
|
||||
"removed_points": removed_count,
|
||||
"keep_ratio": filtered_count / original_count * 100 if original_count > 0 else 0,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
|
||||
class SORFilter:
|
||||
def apply(self, pointcloud: o3d.geometry.PointCloud, nb_neighbors: int = 20, std_ratio: float = 2.0) -> FilterResult:
|
||||
_, indices = pointcloud.remove_statistical_outlier(nb_neighbors=nb_neighbors, std_ratio=std_ratio)
|
||||
inliers = pointcloud.select_by_index(indices)
|
||||
outliers = pointcloud.select_by_index(indices, invert=True)
|
||||
metrics = _base_metrics(len(pointcloud.points), len(inliers.points), "SOR", {
|
||||
"nb_neighbors": nb_neighbors,
|
||||
"std_ratio": std_ratio,
|
||||
})
|
||||
return FilterResult("SOR", inliers, metrics, outliers)
|
||||
|
||||
|
||||
class RORFilter:
|
||||
def apply(self, pointcloud: o3d.geometry.PointCloud, nb_points: int = 5, radius: float = 20.0) -> FilterResult:
|
||||
_, indices = pointcloud.remove_radius_outlier(nb_points=nb_points, radius=radius)
|
||||
inliers = pointcloud.select_by_index(indices)
|
||||
outliers = pointcloud.select_by_index(indices, invert=True)
|
||||
metrics = _base_metrics(len(pointcloud.points), len(inliers.points), "ROR", {
|
||||
"nb_points": nb_points,
|
||||
"radius": radius,
|
||||
})
|
||||
return FilterResult("ROR", inliers, metrics, outliers)
|
||||
|
||||
|
||||
class PassThroughFilter:
|
||||
def apply(
|
||||
self,
|
||||
pointcloud: o3d.geometry.PointCloud,
|
||||
axis: str = "z",
|
||||
min_val: float | None = None,
|
||||
max_val: float | None = None,
|
||||
) -> FilterResult:
|
||||
axis_map = {"x": 0, "y": 1, "z": 2}
|
||||
if axis not in axis_map:
|
||||
raise ValueError("axis must be one of: x, y, z")
|
||||
|
||||
points = np.asarray(pointcloud.points)
|
||||
idx = axis_map[axis]
|
||||
if min_val is None:
|
||||
min_val = float(points[:, idx].min())
|
||||
if max_val is None:
|
||||
max_val = float(points[:, idx].max())
|
||||
|
||||
mask = (points[:, idx] >= min_val) & (points[:, idx] <= max_val)
|
||||
filtered = o3d.geometry.PointCloud()
|
||||
filtered.points = o3d.utility.Vector3dVector(points[mask])
|
||||
if pointcloud.has_colors():
|
||||
colors = np.asarray(pointcloud.colors)
|
||||
filtered.colors = o3d.utility.Vector3dVector(colors[mask])
|
||||
|
||||
metrics = _base_metrics(len(pointcloud.points), len(filtered.points), "PF", {
|
||||
"axis": axis,
|
||||
"min_val": min_val,
|
||||
"max_val": max_val,
|
||||
})
|
||||
return FilterResult("PF", filtered, metrics)
|
||||
|
||||
|
||||
class VoxelGridFilter:
|
||||
def apply(self, pointcloud: o3d.geometry.PointCloud, leaf_size: float = 0.05) -> FilterResult:
|
||||
filtered = pointcloud.voxel_down_sample(leaf_size)
|
||||
metrics = _base_metrics(len(pointcloud.points), len(filtered.points), "VGF", {"leaf_size": leaf_size})
|
||||
metrics["downsample_rate"] = (
|
||||
(len(pointcloud.points) - len(filtered.points)) / len(pointcloud.points) * 100
|
||||
if len(pointcloud.points) > 0
|
||||
else 0
|
||||
)
|
||||
metrics["compression_ratio"] = len(pointcloud.points) / len(filtered.points) if len(filtered.points) > 0 else 0
|
||||
return FilterResult("VGF", filtered, metrics)
|
||||
|
||||
|
||||
class BilateralFilter:
|
||||
def apply(self, pointcloud: o3d.geometry.PointCloud, sigma_s: float = 0.01, sigma_r: float = 0.1) -> FilterResult:
|
||||
if len(pointcloud.points) == 0:
|
||||
metrics = _base_metrics(0, 0, "BF", {"sigma_s": sigma_s, "sigma_r": sigma_r})
|
||||
return FilterResult("BF", copy.deepcopy(pointcloud), metrics)
|
||||
|
||||
pcd = copy.deepcopy(pointcloud)
|
||||
if not pcd.has_normals():
|
||||
pcd.estimate_normals()
|
||||
|
||||
points = np.asarray(pcd.points)
|
||||
normals = np.asarray(pcd.normals)
|
||||
tree = KDTree(points)
|
||||
filtered_points = np.zeros_like(points)
|
||||
|
||||
for i, point in enumerate(points):
|
||||
indices = tree.query_ball_point(point, r=2 * sigma_s)
|
||||
if len(indices) <= 1:
|
||||
filtered_points[i] = point
|
||||
continue
|
||||
|
||||
neighbor_points = points[indices]
|
||||
neighbor_normals = normals[indices]
|
||||
spatial_dist = np.linalg.norm(neighbor_points - point, axis=1)
|
||||
spatial_weight = np.exp(-(spatial_dist ** 2) / (2 * sigma_s ** 2))
|
||||
normal_dist = np.linalg.norm(neighbor_normals - normals[i], axis=1)
|
||||
range_weight = np.exp(-(normal_dist ** 2) / (2 * sigma_r ** 2))
|
||||
weights = spatial_weight * range_weight
|
||||
if weights.sum() > 0:
|
||||
weights = weights / weights.sum()
|
||||
filtered_points[i] = np.sum(neighbor_points.T * weights, axis=1)
|
||||
|
||||
filtered = o3d.geometry.PointCloud()
|
||||
filtered.points = o3d.utility.Vector3dVector(filtered_points)
|
||||
metrics = _base_metrics(len(pointcloud.points), len(filtered.points), "BF", {
|
||||
"sigma_s": sigma_s,
|
||||
"sigma_r": sigma_r,
|
||||
})
|
||||
return FilterResult("BF", filtered, metrics)
|
||||
|
||||
|
||||
class PreprocessingRunner:
|
||||
"""Run configured filters in sequence or independently."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.filters = {
|
||||
"SOR": SORFilter(),
|
||||
"ROR": RORFilter(),
|
||||
"PF": PassThroughFilter(),
|
||||
"VGF": VoxelGridFilter(),
|
||||
"BF": BilateralFilter(),
|
||||
}
|
||||
|
||||
def run(self, pointcloud: o3d.geometry.PointCloud, config: dict[str, Any]) -> dict[str, FilterResult]:
|
||||
results: dict[str, FilterResult] = {}
|
||||
for method in config.get("methods", ["SOR"]):
|
||||
name = method.upper()
|
||||
if name not in self.filters:
|
||||
raise ValueError(f"Unsupported filter method: {method}")
|
||||
if name == "SOR":
|
||||
result = self.filters[name].apply(
|
||||
pointcloud,
|
||||
nb_neighbors=int(config.get("sor_neighbors", 20)),
|
||||
std_ratio=float(config.get("sor_std_ratio", 2.0)),
|
||||
)
|
||||
elif name == "ROR":
|
||||
result = self.filters[name].apply(
|
||||
pointcloud,
|
||||
nb_points=int(config.get("ror_points", 5)),
|
||||
radius=float(config.get("ror_radius", 20.0)),
|
||||
)
|
||||
elif name == "PF":
|
||||
result = self.filters[name].apply(
|
||||
pointcloud,
|
||||
axis=str(config.get("pass_axis", "z")),
|
||||
min_val=config.get("pass_min"),
|
||||
max_val=config.get("pass_max"),
|
||||
)
|
||||
elif name == "VGF":
|
||||
result = self.filters[name].apply(pointcloud, leaf_size=float(config.get("voxel_size", 0.05)))
|
||||
else:
|
||||
result = self.filters[name].apply(
|
||||
pointcloud,
|
||||
sigma_s=float(config.get("bf_sigma_s", 0.01)),
|
||||
sigma_r=float(config.get("bf_sigma_r", 0.1)),
|
||||
)
|
||||
results[name] = result
|
||||
return results
|
||||
29
alg/平整度、平面度、轮廓测量v1.5/preprocessing/noise.py
Normal file
29
alg/平整度、平面度、轮廓测量v1.5/preprocessing/noise.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
|
||||
|
||||
class GaussianNoiseAdder:
|
||||
"""Add Gaussian noise to an Open3D point cloud."""
|
||||
|
||||
def __init__(self, noise_std: float = 4.0, seed: int | None = None) -> None:
|
||||
self.noise_std = noise_std
|
||||
self.seed = seed
|
||||
|
||||
def apply(self, pointcloud: o3d.geometry.PointCloud) -> o3d.geometry.PointCloud:
|
||||
noisy = copy.deepcopy(pointcloud)
|
||||
points = np.asarray(noisy.points)
|
||||
rng = np.random.default_rng(self.seed)
|
||||
noise = rng.normal(0, self.noise_std, points.shape)
|
||||
noisy.points = o3d.utility.Vector3dVector(points + noise)
|
||||
return noisy
|
||||
|
||||
def noise_level(self, clean: o3d.geometry.PointCloud, noisy: o3d.geometry.PointCloud) -> float:
|
||||
clean_points = np.asarray(clean.points)
|
||||
noisy_points = np.asarray(noisy.points)
|
||||
if clean_points.shape != noisy_points.shape:
|
||||
raise ValueError("Clean and noisy point clouds must have the same point count.")
|
||||
return float(np.mean(np.linalg.norm(noisy_points - clean_points, axis=1)))
|
||||
62
alg/平整度、平面度、轮廓测量v1.5/preprocessing/plane.py
Normal file
62
alg/平整度、平面度、轮廓测量v1.5/preprocessing/plane.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import open3d as o3d
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaneResult:
|
||||
model: tuple[float, float, float, float]
|
||||
inliers: list[int]
|
||||
inlier_ratio: float
|
||||
normal_angle_deg: float
|
||||
rmse: float
|
||||
pointcloud: o3d.geometry.PointCloud
|
||||
|
||||
|
||||
class PlaneEstimator:
|
||||
"""Generate noisy planes and estimate planes using Open3D RANSAC."""
|
||||
|
||||
def create_square_plane_with_noise(
|
||||
self,
|
||||
size_mm: float = 1000.0,
|
||||
noise_std_mm: float = 0.5,
|
||||
num_points: int = 10000,
|
||||
) -> o3d.geometry.PointCloud:
|
||||
half_size_m = size_mm / 2000.0
|
||||
noise_std_m = noise_std_mm / 1000.0
|
||||
xy = np.random.uniform(-half_size_m, half_size_m, size=(num_points, 2))
|
||||
z = np.random.normal(0, noise_std_m, size=(num_points, 1))
|
||||
points = np.hstack([xy, z])
|
||||
pcd = o3d.geometry.PointCloud()
|
||||
pcd.points = o3d.utility.Vector3dVector(points)
|
||||
return pcd
|
||||
|
||||
def estimate(
|
||||
self,
|
||||
pointcloud: o3d.geometry.PointCloud,
|
||||
threshold_mm: float = 0.5,
|
||||
max_iterations: int = 2000,
|
||||
) -> PlaneResult:
|
||||
threshold_m = threshold_mm / 1000.0
|
||||
plane_model, inliers = pointcloud.segment_plane(
|
||||
distance_threshold=threshold_m,
|
||||
ransac_n=3,
|
||||
num_iterations=max_iterations,
|
||||
)
|
||||
|
||||
a, b, c, d = plane_model
|
||||
normal = np.array([a, b, c], dtype=np.float64)
|
||||
normal = normal / (np.linalg.norm(normal) + 1e-12)
|
||||
ideal = np.array([0.0, 0.0, 1.0])
|
||||
angle = float(np.degrees(np.arccos(np.clip(abs(np.dot(normal, ideal)), -1.0, 1.0))))
|
||||
|
||||
points = np.asarray(pointcloud.points)
|
||||
inlier_points = points[inliers]
|
||||
distances = np.abs(np.dot(inlier_points, np.array([a, b, c])) + d)
|
||||
rmse = float(np.sqrt(np.mean(distances ** 2))) if len(distances) else 0.0
|
||||
ratio = len(inliers) / len(points) * 100 if len(points) else 0.0
|
||||
|
||||
return PlaneResult(tuple(float(x) for x in plane_model), list(inliers), ratio, angle, rmse, pointcloud)
|
||||
Reference in New Issue
Block a user