码力全开 / 使用法向量夹角算法提取特征点

Created Wed, 06 Aug 2025 15:11:46 +0800 Modified Wed, 06 Aug 2025 16:06:04 +0800
1825 Words 2 min

对于点云特征点提取,主要有如下一些算法:

  • 基于曲率的特征点提取,对于曲率大的地方可能是特征点,比如关节、耳朵边缘
  • 基于法向量变化的特征点提取,对于法向量变化大的地方就是特征点
  • ISS特征点,对噪声鲁棒,适合曲面特征
  • 3D SIFT,基于尺度空间,适合不同尺度的特征
  • 深度学习方法,如PointNet自动学习特征

对于法向量夹角算法,首先计算点云中每个点的法向量,然后计算邻域内法向量的夹角,根据夹角的大小判断是否为特征点。其中法向量计算需要用到PCA,而邻域搜索可以使用k近邻或半径邻域。

其中的数据集来自斯坦福大学的bunny模型,利用其中的bun315.ply数据。

下面是其关键步骤:

  1. 点云加载与预处理,包括利用下采样减少计算量和统计滤波去噪提高精度
  2. 法向量的计算,利用Open3D的estimate_normals计算法向量,通过orient_normals_towards_camera_location统一法向量方向
  3. 特征点提取,构建KDTree进行高效的邻域搜索,并计算每个点与其邻域点的法向量夹角均值,最后根据设定的角度阈值筛选特征点,其中夹角均值越大,表明该点越可能是特征点
  4. 结果可视化,将原始点云显示为灰色,而特征点显示为红色,并绘制法向量夹角均值分布直方图,从而帮助确定合适的阈值

下面是其其实现代码:

import open3d as o3d
import numpy as np
import matplotlib.pyplot as plt

def load_point_cloud(file_path):
    """加载点云数据"""
    pcd = o3d.io.read_point_cloud(file_path)
    if pcd.is_empty():
        raise ValueError("无法加载点云文件,请检查路径是否正确")
    print(f"成功加载点云,包含 {len(pcd.points)} 个点")
    return pcd

def preprocess_point_cloud(pcd, voxel_size=0.008):
    """
    点云预处理:针对脊背调整参数
    脊背细节丰富,采用较小的体素大小保留特征
    """
    # 下采样:体素尺寸减小(默认0.008m),保留脊背细节
    down_pcd = pcd.voxel_down_sample(voxel_size=voxel_size)
    
    # 统计滤波:放宽去噪阈值,避免过滤掉脊背边缘点
    cl, ind = down_pcd.remove_statistical_outlier(nb_neighbors=15, std_ratio=2.5)
    inlier_cloud = down_pcd.select_by_index(ind)
    
    print(f"预处理后点云包含 {len(inlier_cloud.points)} 个点")
    return inlier_cloud

def extract_spine_roi(pcd, z_min_ratio=0.6):
    """
    提取脊背ROI(感兴趣区域):利用高度信息筛选背部区域
    脊背位于身体较高位置,通过z轴坐标过滤
    """
    points = np.asarray(pcd.points)
    # 计算z轴坐标范围,取上部区域作为ROI
    z_max = np.max(points[:, 2])
    z_min = z_max * z_min_ratio  # 可根据实际数据调整比例(0.5-0.7)
    roi_indices = np.where(points[:, 2] > z_min)[0]
    
    spine_roi = pcd.select_by_index(roi_indices)
    print(f"脊背ROI包含 {len(spine_roi.points)} 个点")
    return spine_roi

def compute_normals(pcd, radius=0.04, max_nn=20):
    """
    计算法向量:针对脊背平缓曲面调整参数
    减小搜索半径和邻域点数,提高法向量对局部曲率的敏感性
    """
    pcd.estimate_normals(
        search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=radius, max_nn=max_nn)
    )
    # 法向量方向统一指向身体外侧(脊背朝上,法向量向上)
    pcd.orient_normals_towards_camera_location(camera_location=np.array([0, 0, z_max*1.5]))
    return pcd

def extract_feature_points_by_normal_angle(pcd, k=15, angle_thresh=20):
    """
    提取特征点:针对脊背调整参数
    脊背曲率变化较平缓,降低角度阈值以捕捉细微特征
    """
    kdtree = o3d.geometry.KDTreeFlann(pcd)
    points = np.asarray(pcd.points)
    normals = np.asarray(pcd.normals)
    num_points = len(points)
    
    angle_means = []
    
    for i in range(num_points):
        [_, idx, _] = kdtree.search_knn_vector_3d(points[i], k)
        neighbor_normals = normals[idx[1:], :]
        current_normal = normals[i, :]
        
        # 计算夹角(脊背特征点的邻域法向量夹角较小但有明显变化)
        dots = np.dot(neighbor_normals, current_normal)
        dots = np.clip(dots, -1.0, 1.0)
        angles = np.arccos(dots)
        angle_mean = np.mean(np.rad2deg(angles))
        angle_means.append(angle_mean)
    
    # 脊背特征点的夹角均值通常比四肢小,降低阈值
    angle_means = np.array(angle_means)
    feature_indices = np.where(angle_means > angle_thresh)[0]
    
    feature_pcd = pcd.select_by_index(feature_indices)
    print(f"从脊背ROI中提取到 {len(feature_pcd.points)} 个特征点")
    return feature_pcd, angle_means

def visualize_results(original_pcd, roi_pcd, feature_pcd):
    """可视化:区分原始点云、ROI和特征点"""
    original_pcd.paint_uniform_color([0.8, 0.8, 0.8])  # 原始点云:浅灰
    roi_pcd.paint_uniform_color([0.5, 0.5, 0.5])      # 脊背ROI:深灰
    feature_pcd.paint_uniform_color([1.0, 0.0, 0.0])  # 特征点:红色
    
    o3d.visualization.draw_geometries(
        [original_pcd, roi_pcd, feature_pcd],
        window_name="脊背特征点提取结果",
        width=1200,
        height=800,
        point_show_normal=False
    )

def plot_angle_distribution(angle_means, angle_thresh):
    """绘制脊背区域的夹角分布,辅助调整阈值"""
    plt.figure(figsize=(10, 6))
    plt.hist(angle_means, bins=30, alpha=0.7, color='green')
    plt.axvline(x=angle_thresh, color='r', linestyle='--', label=f'Threshold: {angle_thresh}°')
    plt.xlabel('Mean Angle')
    plt.ylabel('Points')
    plt.title('Distribution')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.show()

def main():
    # 针对脊背的参数设置(单位:米)
    file_path = "bun315.ply"  # 点云文件路径
    voxel_size = 0.008                # 体素大小:较小值保留脊背细节
    z_min_ratio = 0.65                # 脊背ROI的z轴下限比例
    normal_radius = 0.04              # 法向量计算半径:适应脊背宽度
    k_neighbors = 15                  # 邻域点数:减少平滑效应
    angle_threshold = 22              # 夹角阈值:降低以捕捉平缓变化
    
    try:
        # 1. 加载点云
        pcd = load_point_cloud(file_path)
        global z_max  # 全局变量,用于法向量方向调整
        z_max = np.max(np.asarray(pcd.points)[:, 2])
        
        # 2. 预处理(保留细节)
        processed_pcd = preprocess_point_cloud(pcd, voxel_size)
        
        # 3. 提取脊背ROI(关键步骤:减少无关区域干扰)
        spine_roi = extract_spine_roi(processed_pcd, z_min_ratio)
        
        # 4. 计算法向量(适应脊背曲面)
        spine_roi = compute_normals(spine_roi, normal_radius)
        
        # 5. 提取脊背特征点
        feature_pcd, angle_means = extract_feature_points_by_normal_angle(
            spine_roi, 
            k=k_neighbors, 
            angle_thresh=angle_threshold
        )
        
        # 6. 保存结果
        o3d.io.write_point_cloud("spine_feature_points.ply", feature_pcd)
        print("脊背特征点已保存为 pig_spine_feature_points.ply")
        
        # 7. 可视化
        visualize_results(processed_pcd, spine_roi, feature_pcd)
        
        # 8. 分析夹角分布
        plot_angle_distribution(angle_means, angle_threshold)
        
    except Exception as e:
        print(f"处理过程中发生错误: {str(e)}")

其输出如下:

成功加载点云,包含 35336 个点
预处理后点云包含 587 个点
脊背ROI包含 198 个点
从脊背ROI中提取到 74 个特征点

其最终效果如下:

img

从上图可以看出,我们成功将其兔子脊背特征点标注了出来。

如果喜欢这篇文章或对您有帮助,可以:[☕] 请我喝杯咖啡 | [💓] 小额赞助