Skip to content

Commit b2af2b8

Browse files
Merge pull request #149 from diffCheckOrg/implement_pca
Add axis calculation component for pose estimation
2 parents eb3ab9a + 4d2f3b1 commit b2af2b8

File tree

17 files changed

+506
-2
lines changed

17 files changed

+506
-2
lines changed
-43.7 MB
Binary file not shown.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ module = [
2020
"GH_IO.*",
2121
"clr.*",
2222
"diffcheck_bindings",
23-
"diffCheck.diffcheck_bindings"
23+
"diffCheck.diffcheck_bindings",
24+
"ghpythonlib.*"
2425
]
2526
ignore_missing_imports = true
2627

src/diffCheck.hh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
#include <loguru.hpp>
77

88
#include <cilantro/cilantro.hpp>
9+
#include <cilantro/clustering/kmeans.hpp>
10+
11+
#include <Eigen/Dense>
912

1013
// diffCheck includes
1114
#include "diffCheck/log.hh"

src/diffCheck/IOManager.cc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,11 @@ namespace diffCheck::io
6868
std::filesystem::path pathCloud = pathTestData / "test_pc_for_SOR_101pts_with_1_outlier.ply";
6969
return pathCloud.string();
7070
}
71+
72+
std::string GetTwoConnectedPlanesPlyPath()
73+
{
74+
std::filesystem::path pathTestData = GetTestDataDir();
75+
std::filesystem::path pathCloud = pathTestData / "two_connected_planes_with_normals.ply";
76+
return pathCloud.string();
77+
}
7178
} // namespace diffCheck::io

src/diffCheck/IOManager.hh

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,6 @@ namespace diffCheck::io
4242
std::string GetRoofQuarterPlyPath();
4343
/// @brief Get the path to the plane point cloud with one outlier
4444
std::string GetPlanePCWithOneOutliers();
45+
/// @brief Get the path to the two connected planes ply test file
46+
std::string GetTwoConnectedPlanesPlyPath();
4547
} // namespace diffCheck::io

src/diffCheck/geometry/DFPointCloud.cc

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

219+
std::vector<Eigen::Vector3d> DFPointCloud::GetPrincipalAxes(int nComponents)
220+
{
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)
249+
{
250+
sortedClustersBySize[i] = {clusterSizes[i], centroids.col(i)};
251+
}
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;
287+
}
288+
219289
void DFPointCloud::Crop(const Eigen::Vector3d &minBound, const Eigen::Vector3d &maxBound)
220290
{
221291
auto O3DPointCloud = this->Cvt2O3DPointCloud();

src/diffCheck/geometry/DFPointCloud.hh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
#include <cilantro/utilities/point_cloud.hpp>
1010
#include <cilantro/core/nearest_neighbors.hpp>
11+
#include <cilantro/clustering/kmeans.hpp>
12+
1113

1214
namespace diffCheck::geometry
1315
{
@@ -89,6 +91,14 @@ namespace diffCheck::geometry
8991
*/
9092
void RemoveStatisticalOutliers(int nbNeighbors, double stdRatio);
9193

94+
/**
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
99+
*/
100+
std::vector<Eigen::Vector3d> GetPrincipalAxes(int nComponents = 6);
101+
92102
/**
93103
* @brief Crop the point cloud to a bounding box defined by the min and max bounds
94104
*

src/diffCheckBindings.cc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ 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)
6466
.def("crop",
6567
(void (diffCheck::geometry::DFPointCloud::*)(const Eigen::Vector3d&, const Eigen::Vector3d&))
6668
&diffCheck::geometry::DFPointCloud::Crop,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#! python3
2+
3+
from diffCheck import df_cvt_bindings
4+
from diffCheck import df_poses
5+
6+
import Rhino
7+
from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML
8+
9+
from ghpythonlib.componentbase import executingcomponent as component
10+
import System
11+
12+
13+
class DFPoseEstimation(component):
14+
def RunScript(self,
15+
i_clouds: System.Collections.Generic.List[Rhino.Geometry.PointCloud],
16+
i_assembly,
17+
i_save: bool,
18+
i_reset: bool):
19+
20+
# ensure assembly has enough beams
21+
if len(i_assembly.beams) < len(i_clouds):
22+
ghenv.Component.AddRuntimeMessage(RML.Warning, "Assembly has fewer beams than input clouds") # noqa: F821
23+
return None, None
24+
25+
planes = []
26+
all_poses_in_time = df_poses.DFPosesAssembly()
27+
if i_reset:
28+
all_poses_in_time.reset()
29+
return None, None
30+
31+
all_poses_this_time = []
32+
for i, cloud in enumerate(i_clouds):
33+
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
39+
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])
43+
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]))
48+
49+
new_xDirection, new_yDirection = df_poses.select_vectors(vectors, i_assembly.beams[i].plane.XAxis, i_assembly.beams[i].plane.YAxis)
50+
51+
pose = df_poses.DFPose(
52+
origin = [rh_point.X, rh_point.Y, rh_point.Z],
53+
xDirection = [new_xDirection.X, new_xDirection.Y, new_xDirection.Z],
54+
yDirection = [new_yDirection.X, new_yDirection.Y, new_yDirection.Z])
55+
all_poses_this_time.append(pose)
56+
plane = Rhino.Geometry.Plane(origin = rh_point, xDirection=new_xDirection, yDirection=new_yDirection)
57+
planes.append(plane)
58+
except Exception as e:
59+
# Any unexpected error on this cloud, skip it and keep going
60+
ghenv.Component.AddRuntimeMessage(RML.Error, f"Cloud {i}: processing failed ({e}); skipping.") # noqa: F821
61+
planes.append(None)
62+
all_poses_this_time.append(None)
63+
continue
64+
65+
if i_save:
66+
all_poses_in_time.add_step(all_poses_this_time)
67+
68+
return [planes, all_poses_in_time.to_gh_tree()]
1.13 KB
Loading

0 commit comments

Comments
 (0)