Skip to content

Commit b512b99

Browse files
authored
Merge pull request #168 from diffCheckOrg/implement_pca_patch/plane_detec
Implement pca patch/plane detec
2 parents 8c54bc5 + 71ea6bc commit b512b99

File tree

11 files changed

+100
-130
lines changed

11 files changed

+100
-130
lines changed

src/diffCheck/geometry/DFPointCloud.cc

Lines changed: 12 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -216,74 +216,21 @@ namespace diffCheck::geometry
216216
this->Normals.push_back(normal);
217217
}
218218

219-
std::vector<Eigen::Vector3d> DFPointCloud::GetPrincipalAxes(int nComponents)
219+
Eigen::Vector3d DFPointCloud::FitPlaneRANSAC(
220+
double distanceThreshold,
221+
int ransacN,
222+
int numIterations)
220223
{
221-
std::vector<Eigen::Vector3d> principalAxes;
222-
223-
if (! this->HasNormals())
224-
{
225-
DIFFCHECK_WARN("The point cloud has no normals. Normals will be estimated with knn = 20.");
226-
this->EstimateNormals(true, 20);
227-
}
228-
229-
// Convert normals to Eigen matrix
230-
Eigen::Matrix<double, 3, Eigen::Dynamic> normalMatrix(3, this->Normals.size());
231-
for (size_t i = 0; i < this->Normals.size(); ++i)
232-
{
233-
normalMatrix.col(i) = this->Normals[i].cast<double>();
234-
}
235-
236-
cilantro::KMeans<double, 3> kmeans(normalMatrix);
237-
kmeans.cluster(nComponents);
238-
239-
const cilantro::VectorSet3d& centroids = kmeans.getClusterCentroids();
240-
const std::vector<size_t>& assignments = kmeans.getPointToClusterIndexMap();
241-
std::vector<int> clusterSizes(nComponents, 0);
242-
for (size_t i = 0; i < assignments.size(); ++i)
243-
{
244-
clusterSizes[assignments[i]]++;
245-
}
246-
// Sort clusters by size
247-
std::vector<std::pair<int, Eigen::Vector3d>> sortedClustersBySize(nComponents);
248-
for (size_t i = 0; i < nComponents; ++i)
224+
if (this->Points.size() < ransacN)
249225
{
250-
sortedClustersBySize[i] = {clusterSizes[i], centroids.col(i)};
226+
DIFFCHECK_ERROR("Not enough points to fit a plane with RANSAC.");
227+
return Eigen::Vector3d::Zero();
251228
}
252-
std::sort(sortedClustersBySize.begin(), sortedClustersBySize.end(), [](const auto& a, const auto& b)
253-
{
254-
return a.first > b.first;
255-
});
256-
257-
for(size_t i = 0; i < nComponents; ++i)
258-
{
259-
if(principalAxes.size() == 0)
260-
{
261-
principalAxes.push_back(sortedClustersBySize[i].second);
262-
}
263-
else
264-
{
265-
bool isAlreadyPresent = false;
266-
for (const auto& axis : principalAxes)
267-
{
268-
double dotProduct = std::abs(axis.dot(sortedClustersBySize[i].second));
269-
if (std::abs(dotProduct) > 0.7) // Threshold to consider as similar direction
270-
{
271-
isAlreadyPresent = true;
272-
break;
273-
}
274-
}
275-
if (!isAlreadyPresent)
276-
{
277-
principalAxes.push_back(sortedClustersBySize[i].second);
278-
}
279-
}
280-
}
281-
if (principalAxes.size() < 2) // Fallback to OBB if k-means fails to provide enough distinct axes
282-
{
283-
open3d::geometry::OrientedBoundingBox obb = this->Cvt2O3DPointCloud()->GetOrientedBoundingBox();
284-
principalAxes = {obb.R_.col(0), obb.R_.col(1), obb.R_.col(2)};
285-
}
286-
return principalAxes;
229+
230+
auto O3DPointCloud = this->Cvt2O3DPointCloud();
231+
std::tuple< Eigen::Vector4d, std::vector<size_t>> planeModel = O3DPointCloud->SegmentPlane(distanceThreshold, ransacN, numIterations);
232+
Eigen::Vector3d planeParameters = std::get<0>(planeModel).head<3>();
233+
return planeParameters;
287234
}
288235

289236
void DFPointCloud::Crop(const Eigen::Vector3d &minBound, const Eigen::Vector3d &maxBound)

src/diffCheck/geometry/DFPointCloud.hh

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,17 @@ namespace diffCheck::geometry
9292
void RemoveStatisticalOutliers(int nbNeighbors, double stdRatio);
9393

9494
/**
95-
* @brief Get the nCompoments principal axes of the normals of the point cloud
96-
* It is used to compute the pose of "boxy" point clouds. It relies on KMeans clustering to find the main axes of the point cloud.
97-
* @param nComponents the number of components to compute (default 6, each of 3 main axes in both directions)
98-
* @return std::vector<Eigen::Vector3d> the principal axes of the point cloud ordered by number of normals
95+
* @brief Fit a plane to the point cloud using RANSAC
96+
*
97+
* @param distanceThreshold the distance threshold to consider a point as an inlier
98+
* @param ransacN the number of points to sample for each RANSAC iteration
99+
* @param numIterations the number of RANSAC iterations
100+
* @return The Normal vector of the fitted plane as an Eigen::Vector3d
99101
*/
100-
std::vector<Eigen::Vector3d> GetPrincipalAxes(int nComponents = 6);
102+
Eigen::Vector3d FitPlaneRANSAC(
103+
double distanceThreshold = 0.01,
104+
int ransacN = 3,
105+
int numIterations = 100);
101106

102107
/**
103108
* @brief Crop the point cloud to a bounding box defined by the min and max bounds

src/diffCheck/segmentation/DFSegmentation.cc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ namespace diffCheck::segmentation
330330
void DFSegmentation::CleanUnassociatedClusters(
331331
bool isCylinder,
332332
std::vector<std::shared_ptr<geometry::DFPointCloud>> &unassociatedClusters,
333-
std::vector<std::shared_ptr<geometry::DFPointCloud>> &existingPointCloudSegments,
333+
std::vector<std::vector<std::shared_ptr<geometry::DFPointCloud>>> &existingPointCloudSegments,
334334
std::vector<std::vector<std::shared_ptr<geometry::DFMesh>>> meshes,
335335
double angleThreshold,
336336
double associationThreshold)
@@ -459,12 +459,12 @@ namespace diffCheck::segmentation
459459
DIFFCHECK_WARN("No mesh face found for the cluster. Skipping the cluster.");
460460
continue;
461461
}
462-
if (goodMeshIndex >= existingPointCloudSegments.size())
462+
if (goodMeshIndex >= existingPointCloudSegments.size() || goodFaceIndex >= existingPointCloudSegments[goodMeshIndex].size())
463463
{
464464
DIFFCHECK_WARN("No segment found for the face. Skipping the face.");
465465
continue;
466466
}
467-
std::shared_ptr<geometry::DFPointCloud> completed_segment = existingPointCloudSegments[goodMeshIndex];
467+
std::shared_ptr<geometry::DFPointCloud> completed_segment = existingPointCloudSegments[goodMeshIndex][goodFaceIndex];
468468

469469
for (Eigen::Vector3d point : cluster->Points)
470470
{

src/diffCheck/segmentation/DFSegmentation.hh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ namespace diffCheck::segmentation
4444
/** @brief Iterated through clusters and finds the corresponding mesh face. It then associates the points of the cluster that are on the mesh face to the segment already associated with the mesh face.
4545
* @param isCylinder a boolean to indicate if the model is a cylinder. If true, the method will use the GetCenterAndAxis method of the mesh to find the center and axis of the mesh. based on that, we only want points that have normals more or less perpendicular to the cylinder axis.
4646
* @param unassociatedClusters the clusters from the normal-based segmentatinon that haven't been associated yet.
47-
* @param existingPointCloudSegments the already associated segments
47+
* @param existingPointCloudSegments the already associated segments per mesh face.
4848
* @param meshes the mesh faces for all the model. This is used to associate the clusters to the mesh faces.
4949
* * @param angleThreshold the threshold to consider the a cluster as potential candidate for association. the value passed is the minimum sine of the angles. A value of 0 requires perfect alignment (angle = 0), while a value of 0.1 allows an angle of 5.7 degrees.
5050
* @param associationThreshold the threshold to consider the points of a segment and a mesh face as associable. It is the ratio between the surface of the closest mesh triangle and the sum of the areas of the three triangles that form the rest of the pyramid described by the mesh triangle and the point we want to associate or not. The lower the number, the more strict the association will be and some poinnts on the mesh face might be wrongfully excluded.
5151
*/
5252
static void DFSegmentation::CleanUnassociatedClusters(
5353
bool isCylinder,
5454
std::vector<std::shared_ptr<geometry::DFPointCloud>> &unassociatedClusters,
55-
std::vector<std::shared_ptr<geometry::DFPointCloud>> &existingPointCloudSegments,
55+
std::vector<std::vector<std::shared_ptr<geometry::DFPointCloud>>> &existingPointCloudSegments,
5656
std::vector<std::vector<std::shared_ptr<geometry::DFMesh>>> meshes,
5757
double angleThreshold = 0.1,
5858
double associationThreshold = 0.1);

src/diffCheckBindings.cc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,11 @@ PYBIND11_MODULE(diffcheck_bindings, m) {
6161
.def("remove_statistical_outliers", &diffCheck::geometry::DFPointCloud::RemoveStatisticalOutliers,
6262
py::arg("nb_neighbors"), py::arg("std_ratio"))
6363

64-
.def("get_principal_axes", &diffCheck::geometry::DFPointCloud::GetPrincipalAxes,
65-
py::arg("n_components") = 6)
64+
.def("fit_plane_ransac", &diffCheck::geometry::DFPointCloud::FitPlaneRANSAC,
65+
py::arg("distance_threshold") = 0.01,
66+
py::arg("ransac_n") = 3,
67+
py::arg("num_iterations") = 100)
68+
6669
.def("crop",
6770
(void (diffCheck::geometry::DFPointCloud::*)(const Eigen::Vector3d&, const Eigen::Vector3d&))
6871
&diffCheck::geometry::DFPointCloud::Crop,

src/gh/components/DF_CAD_segmentator/code.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import Rhino
66
from ghpythonlib.componentbase import executingcomponent as component
77
from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML
8+
import ghpythonlib.treehelpers as th
89

910

1011
from diffCheck.diffcheck_bindings import dfb_segmentation
@@ -19,7 +20,7 @@ def RunScript(self,
1920
i_clouds: System.Collections.Generic.IList[Rhino.Geometry.PointCloud],
2021
i_assembly,
2122
i_angle_threshold: float = 0.1,
22-
i_association_threshold: float = 0.1) -> Rhino.Geometry.PointCloud:
23+
i_association_threshold: float = 0.1):
2324

2425
if i_clouds is None or i_assembly is None:
2526
self.AddRuntimeMessage(RML.Warning, "Please provide a cloud and an assembly to segment.")
@@ -29,20 +30,18 @@ def RunScript(self,
2930
if i_association_threshold is None:
3031
i_association_threshold = 0.1
3132

32-
o_clusters = []
33+
o_face_clusters = []
3334
df_clusters = []
3435
# we make a deepcopy of the input clouds
3536
df_clouds = [df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud.Duplicate()) for cloud in i_clouds]
3637

3738
df_beams = i_assembly.beams
38-
df_beams_meshes = []
39-
rh_beams_meshes = []
4039

4140
for df_b in df_beams:
41+
o_face_clusters.append([])
42+
4243
rh_b_mesh_faces = [df_b_f.to_mesh() for df_b_f in df_b.side_faces]
4344
df_b_mesh_faces = [df_cvt_bindings.cvt_rhmesh_2_dfmesh(rh_b_mesh_face) for rh_b_mesh_face in rh_b_mesh_faces]
44-
df_beams_meshes.append(df_b_mesh_faces)
45-
rh_beams_meshes.append(rh_b_mesh_faces)
4645

4746
# different association depending on the type of beam
4847
df_asssociated_cluster_faces = dfb_segmentation.DFSegmentation.associate_clusters(
@@ -53,27 +52,30 @@ def RunScript(self,
5352
association_threshold=i_association_threshold
5453
)
5554

56-
df_asssociated_cluster = dfb_geometry.DFPointCloud()
57-
for df_associated_face in df_asssociated_cluster_faces:
58-
df_asssociated_cluster.add_points(df_associated_face)
59-
6055
dfb_segmentation.DFSegmentation.clean_unassociated_clusters(
6156
is_roundwood=df_b.is_roundwood,
6257
unassociated_clusters=df_clouds,
63-
associated_clusters=[df_asssociated_cluster],
58+
associated_clusters=[df_asssociated_cluster_faces],
6459
reference_mesh=[df_b_mesh_faces],
6560
angle_threshold=i_angle_threshold,
6661
association_threshold=i_association_threshold
6762
)
6863

64+
o_face_clusters[-1] = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_asssociated_cluster_faces]
65+
66+
df_asssociated_cluster = dfb_geometry.DFPointCloud()
67+
for df_associated_face in df_asssociated_cluster_faces:
68+
df_asssociated_cluster.add_points(df_associated_face)
69+
6970
df_clusters.append(df_asssociated_cluster)
7071

71-
o_clusters = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_clusters]
72+
o_beam_clouds = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_clusters]
7273

73-
for o_cluster in o_clusters:
74-
if not o_cluster.IsValid:
75-
o_cluster = None
74+
for i, o_beam_cloud in enumerate(o_beam_clouds):
75+
if not o_beam_cloud.IsValid:
76+
o_beam_clouds[i] = None
7677
ghenv.Component.AddRuntimeMessage(RML.Warning, "Some beams could not be segmented and were replaced by 'None'") # noqa: F821
7778

79+
o_face_clouds = th.list_to_tree(o_face_clusters)
7880

79-
return o_clusters
81+
return [o_beam_clouds, o_face_clouds]

src/gh/components/DF_CAD_segmentator/metadata.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,17 @@
6464
],
6565
"outputParameters": [
6666
{
67-
"name": "o_clusters",
68-
"nickname": "o_clusters",
69-
"description": "The clouds associated to each beam.",
67+
"name": "o_beam_clouds",
68+
"nickname": "o_beam_clouds",
69+
"description": "The merged clouds associated to each beam.",
70+
"optional": false,
71+
"sourceCount": 0,
72+
"graft": false
73+
},
74+
{
75+
"name": "o_face_clouds",
76+
"nickname": "o_face_clouds",
77+
"description": "The datatree of clouds associated to each face.",
7078
"optional": false,
7179
"sourceCount": 0,
7280
"graft": false

src/gh/components/DF_pose_estimation/code.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
1+
"""This compoment calculates the pose of a data tree of point clouds."""
12
#! python3
23

34
from diffCheck import df_cvt_bindings
45
from diffCheck import df_poses
6+
from diffCheck.diffcheck_bindings import dfb_geometry
57

68
import Rhino
79
from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML
10+
import Grasshopper
11+
import ghpythonlib.treehelpers as th
812

913
from ghpythonlib.componentbase import executingcomponent as component
10-
import System
1114

1215

1316
class DFPoseEstimation(component):
1417
def RunScript(self,
15-
i_clouds: System.Collections.Generic.List[Rhino.Geometry.PointCloud],
18+
i_face_clouds: Grasshopper.DataTree[Rhino.Geometry.PointCloud],
1619
i_assembly,
17-
i_save: bool,
18-
i_reset: bool):
20+
i_reset: bool,
21+
i_save: bool):
1922

23+
clusters_per_beam = th.tree_to_list(i_face_clouds)
2024
# ensure assembly has enough beams
21-
if len(i_assembly.beams) < len(i_clouds):
25+
if len(i_assembly.beams) < len(clusters_per_beam):
2226
ghenv.Component.AddRuntimeMessage(RML.Warning, "Assembly has fewer beams than input clouds") # noqa: F821
2327
return None, None
2428

@@ -29,32 +33,34 @@ def RunScript(self,
2933
return None, None
3034

3135
all_poses_this_time = []
32-
for i, cloud in enumerate(i_clouds):
36+
for i, face_clouds in enumerate(clusters_per_beam):
3337
try:
34-
df_cloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud)
35-
if df_cloud is None:
36-
return None, None
37-
if not df_cloud.has_normals():
38-
ghenv.Component.AddRuntimeMessage(RML.Error, f"Point cloud {i} has no normals. Please compute the normals.") # noqa: F821
38+
df_cloud = dfb_geometry.DFPointCloud()
3939

40-
df_points = df_cloud.get_axis_aligned_bounding_box()
41-
df_point = (df_points[0] + df_points[1]) / 2
42-
rh_point = Rhino.Geometry.Point3d(df_point[0], df_point[1], df_point[2])
40+
rh_face_normals = []
41+
for face_cloud in face_clouds:
42+
df_face_cloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(face_cloud)
43+
df_cloud.add_points(df_face_cloud)
44+
plane_normal = df_face_cloud.fit_plane_ransac()
45+
if all(plane_normal) == 0:
46+
ghenv.Component.AddRuntimeMessage(RML.Warning, f"There was a missing face in the cloud of beam {i}: the face was skipped in the pose estimation of that beam") # noqa: F821
47+
continue
48+
rh_face_normals.append(Rhino.Geometry.Vector3d(plane_normal[0], plane_normal[1], plane_normal[2]))
4349

44-
axes = df_cloud.get_principal_axes(3)
45-
vectors = []
46-
for axe in axes:
47-
vectors.append(Rhino.Geometry.Vector3d(axe[0], axe[1], axe[2]))
50+
df_bb_points = df_cloud.get_axis_aligned_bounding_box()
51+
df_bb_centroid = (df_bb_points[0] + df_bb_points[1]) / 2
52+
rh_bb_centroid = Rhino.Geometry.Point3d(df_bb_centroid[0], df_bb_centroid[1], df_bb_centroid[2])
4853

49-
new_xDirection, new_yDirection = df_poses.select_vectors(vectors, i_assembly.beams[i].plane.XAxis, i_assembly.beams[i].plane.YAxis)
54+
new_xDirection, new_yDirection = df_poses.select_vectors(rh_face_normals, i_assembly.beams[i].plane.XAxis, i_assembly.beams[i].plane.YAxis)
5055

5156
pose = df_poses.DFPose(
52-
origin = [rh_point.X, rh_point.Y, rh_point.Z],
57+
origin = [rh_bb_centroid.X, rh_bb_centroid.Y, rh_bb_centroid.Z],
5358
xDirection = [new_xDirection.X, new_xDirection.Y, new_xDirection.Z],
5459
yDirection = [new_yDirection.X, new_yDirection.Y, new_yDirection.Z])
5560
all_poses_this_time.append(pose)
56-
plane = Rhino.Geometry.Plane(origin = rh_point, xDirection=new_xDirection, yDirection=new_yDirection)
61+
plane = Rhino.Geometry.Plane(origin = rh_bb_centroid, xDirection=new_xDirection, yDirection=new_yDirection)
5762
planes.append(plane)
63+
5864
except Exception as e:
5965
# Any unexpected error on this cloud, skip it and keep going
6066
ghenv.Component.AddRuntimeMessage(RML.Error, f"Cloud {i}: processing failed ({e}); skipping.") # noqa: F821

src/gh/components/DF_pose_estimation/metadata.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
"iconDisplay": 2,
1515
"inputParameters": [
1616
{
17-
"name": "i_clouds",
18-
"nickname": "i_clouds",
19-
"description": "clouds whose pose is to be calculated",
17+
"name": "i_face_clouds",
18+
"nickname": "i_face_clouds",
19+
"description": "datatree of beam clouds whose pose is to be calculated",
2020
"optional": false,
2121
"allowTreeAccess": true,
2222
"showTypeHints": true,

test_save.ply

Lines changed: 0 additions & 3 deletions
This file was deleted.

0 commit comments

Comments
 (0)