Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d5ed24e
feat: add principal axes calculation for pointclouds for pose estimation
DamienGilliard Apr 9, 2025
f43e197
fix: add cilantro KMeans clustering to main header
DamienGilliard Apr 9, 2025
4809f30
feat: add empty component for axis calculation. code must be written
DamienGilliard Apr 9, 2025
3486fa4
feat: add python binding for axis calculation
DamienGilliard Apr 9, 2025
ad8e895
feat: add poses module for pose historic saving
DamienGilliard May 7, 2025
4d5c71b
feat-wip: add code to main axis calculation component. OK but reliabi…
DamienGilliard May 7, 2025
256fd88
feat: add 'save' mathod to DFPoses classes to dump it in a JSON
DamienGilliard May 9, 2025
3971d40
fix: update the metadata of the axis calculation component
DamienGilliard May 9, 2025
13c4759
feat: make pose calculation component leaner
DamienGilliard May 9, 2025
78231f7
feat: slight refactor of df_poses module
DamienGilliard May 9, 2025
d967ea8
fix: simplify k-means clustering implementation
DamienGilliard May 9, 2025
9aadeff
Merge branch 'patch_ws' into implement_pca
DamienGilliard May 9, 2025
188dc81
feat: add unit test to KMeans clustering of normals
DamienGilliard May 10, 2025
56a80d5
fix: marameter name from singular to plural, and corresponding list a…
DamienGilliard May 16, 2025
0d98ad0
fix: have new elements pick-up the indexes of the already-existing el…
DamienGilliard May 28, 2025
00ceefa
fix: typo in nickname of i_clouds
DamienGilliard May 28, 2025
961b28b
fix: committing insignificant changes to example file because ignorin…
DamienGilliard Aug 10, 2025
3828e8e
fix: improvements to code component and metadata following @eleniv3d …
DamienGilliard Aug 10, 2025
2181ad9
feat: add plane property to DFBeam
DamienGilliard Aug 11, 2025
cedfcc1
feat: add max id to CAD segmentation to allow segmentation during fab…
DamienGilliard Aug 11, 2025
953f5f4
feat: remove the i_stop_after_id parameter because it is moved to a d…
DamienGilliard Sep 2, 2025
adf1576
feat: create new truncate_assembly component
DamienGilliard Sep 2, 2025
f7016b4
fix: main axis of DFBeam computed such that the main axes are also th…
DamienGilliard Sep 2, 2025
de7b580
fix: add ghpythonlib. to the overrides of mypy
DamienGilliard Sep 2, 2025
5745db8
feat: update DFMainPCAxes to save the new poses only when user trigge…
DamienGilliard Sep 2, 2025
a4e54d2
fix: name of class in truncate_assembly component
DamienGilliard Sep 9, 2025
a637b5d
fix: remove unused function parameter in DF_CAD_segmentator component
DamienGilliard Sep 9, 2025
2513713
fix: change index in TruncateAssembly component
DamienGilliard Oct 2, 2025
a851476
fix: change sign of index change in TruncateAssembly component
DamienGilliard Oct 2, 2025
b00e145
fix: output the history as ghtree
DamienGilliard Oct 15, 2025
6b38623
feat: add fallback to obb when knn on normals gives insufficiently di…
DamienGilliard Oct 15, 2025
daff8bb
ADD check i_assembly has enough beams and if there is an error on a c…
eleniv3d Oct 20, 2025
067903e
FIX rename files assossiated with Pose Estimation component to have t…
eleniv3d Oct 20, 2025
1fad289
FIX typo and use not all sticky but a single namespaced key df_poses
eleniv3d Oct 20, 2025
2995041
FIX remove re-assignying new_xDirection and fix projection to use tth…
eleniv3d Oct 20, 2025
f29ec53
Merge pull request #163 from diffCheckOrg/implement_pca_patch/improve…
eleniv3d Oct 20, 2025
22d4fda
Merge branch 'release/2.0.0' into implement_pca
DamienGilliard Oct 20, 2025
4d2f3b1
fix: small merge issue
DamienGilliard Oct 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/diffCheck.hh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
#include <loguru.hpp>

#include <cilantro/cilantro.hpp>
#include <cilantro/clustering/kmeans.hpp>

#include <Eigen/Dense>

// diffCheck includes
#include "diffCheck/log.hh"
Expand Down
7 changes: 7 additions & 0 deletions src/diffCheck/IOManager.cc
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,11 @@ namespace diffCheck::io
std::filesystem::path pathCloud = pathTestData / "test_pc_for_SOR_101pts_with_1_outlier.ply";
return pathCloud.string();
}

std::string GetTwoConnectedPlanesPlyPath()
{
std::filesystem::path pathTestData = GetTestDataDir();
std::filesystem::path pathCloud = pathTestData / "two_connected_planes_with_normals.ply";
return pathCloud.string();
}
} // namespace diffCheck::io
2 changes: 2 additions & 0 deletions src/diffCheck/IOManager.hh
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ namespace diffCheck::io
std::string GetRoofQuarterPlyPath();
/// @brief Get the path to the plane point cloud with one outlier
std::string GetPlanePCWithOneOutliers();
/// @brief Get the path to the two connected planes ply test file
std::string GetTwoConnectedPlanesPlyPath();
} // namespace diffCheck::io
45 changes: 45 additions & 0 deletions src/diffCheck/geometry/DFPointCloud.cc
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,51 @@ namespace diffCheck::geometry
this->Normals.push_back(normal);
}

std::vector<Eigen::Vector3d> DFPointCloud::GetPrincipalAxes(int nComponents)
{
std::vector<Eigen::Vector3d> principalAxes;

if (! this->HasNormals())
{
DIFFCHECK_WARN("The point cloud has no normals. Normals will be estimated with knn = 20.");
this->EstimateNormals(true, 20);
}

// Convert normals to Eigen matrix
Eigen::Matrix<double, 3, Eigen::Dynamic> normalMatrix(3, this->Normals.size());
for (size_t i = 0; i < this->Normals.size(); ++i)
{
normalMatrix.col(i) = this->Normals[i].cast<double>();
}

cilantro::KMeans<double, 3> kmeans(normalMatrix);
kmeans.cluster(nComponents);

const cilantro::VectorSet3d& centroids = kmeans.getClusterCentroids();
const std::vector<size_t>& assignments = kmeans.getPointToClusterIndexMap();
std::vector<int> clusterSizes(nComponents, 0);
for (size_t i = 0; i < assignments.size(); ++i)
{
clusterSizes[assignments[i]]++;
}
// Sort clusters by size
std::vector<std::pair<int, Eigen::Vector3d>> sortedClustersBySize(nComponents);
for (size_t i = 0; i < nComponents; ++i)
{
sortedClustersBySize[i] = {clusterSizes[i], centroids.col(i)};
}
std::sort(sortedClustersBySize.begin(), sortedClustersBySize.end(), [](const auto& a, const auto& b)
{
return a.first > b.first;
});

for(size_t i = 0; i < nComponents; ++i)
{
principalAxes.push_back(sortedClustersBySize[i].second);
}
return principalAxes;
}

void DFPointCloud::UniformDownsample(int everyKPoints)
{
auto O3DPointCloud = this->Cvt2O3DPointCloud();
Expand Down
10 changes: 10 additions & 0 deletions src/diffCheck/geometry/DFPointCloud.hh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

#include <cilantro/utilities/point_cloud.hpp>
#include <cilantro/core/nearest_neighbors.hpp>
#include <cilantro/clustering/kmeans.hpp>


namespace diffCheck::geometry
{
Expand Down Expand Up @@ -89,6 +91,14 @@ namespace diffCheck::geometry
*/
void RemoveStatisticalOutliers(int nbNeighbors, double stdRatio);

/**
* @brief Get the nCompoments principal axes of the normals of the point cloud
* 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.
* @param nComponents the number of components to compute (default 6, each of 3 main axes in both directions)
* @return std::vector<Eigen::Vector3d> the principal axes of the point cloud ordered by number of normals
*/
std::vector<Eigen::Vector3d> GetPrincipalAxes(int nComponents = 6);

public: ///< Downsamplers
/**
* @brief Downsample the point cloud with voxel grid
Expand Down
3 changes: 3 additions & 0 deletions src/diffCheckBindings.cc
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ PYBIND11_MODULE(diffcheck_bindings, m) {
.def("remove_statistical_outliers", &diffCheck::geometry::DFPointCloud::RemoveStatisticalOutliers,
py::arg("nb_neighbors"), py::arg("std_ratio"))

.def("get_principal_axes", &diffCheck::geometry::DFPointCloud::GetPrincipalAxes,
py::arg("n_components") = 6)

.def("load_from_PLY", &diffCheck::geometry::DFPointCloud::LoadFromPLY)
.def("save_to_PLY", &diffCheck::geometry::DFPointCloud::SaveToPLY)

Expand Down
69 changes: 69 additions & 0 deletions src/gh/components/DF_main_pc_axes/code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#! python3

from diffCheck import diffcheck_bindings
from diffCheck import df_cvt_bindings
from diffCheck import df_poses

import Rhino

from ghpythonlib.componentbase import executingcomponent as component

import System

def compute_dot_product(v1, v2):
"""
Compute the dot product of two vectors.
"""
return (v1.X * v2.X) + (v1.Y * v2.Y) + (v1.Z * v2.Z)
Comment thread
DamienGilliard marked this conversation as resolved.
Outdated

class DFMainPCAxes(component):
def RunScript(self,
i_clouds: System.Collections.Generic.List[Rhino.Geometry.PointCloud],
i_reset: bool):

planes = []
all_poses_in_time = df_poses.DFPosesAssembly()
if i_reset:
all_poses_in_time.reset()
return None, None

previous_poses = all_poses_in_time.get_last_poses()
all_poses_this_time = []
for i, cloud in enumerate(i_clouds):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to check that i_clouds is not empty.

so add sth like:

if not i_clouds:
            return None, None

Copy link
Copy Markdown
Collaborator Author

@DamienGilliard DamienGilliard Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, even better I have set i_clouds as not optional in the metadata, so we get "i_cloud parameter failed to collect data" and the code is simply not run. Let me know if you think this is not a satisfactory solution and if I also should implement it in the code :)

df_cloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud)
if df_cloud is None:
return None, None
df_cloud.estimate_normals(True, 12)
Comment thread
DamienGilliard marked this conversation as resolved.
Outdated

df_points = df_cloud.get_axis_aligned_bounding_box()
df_point = (df_points[0] + df_points[1]) / 2
rh_point = Rhino.Geometry.Point3d(df_point[0], df_point[1], df_point[2])
vectors = []
# Get the main axes of the point cloud
previous_pose = previous_poses[i] if previous_poses else None
if previous_pose:
rh_previous_xDirection = Rhino.Geometry.Vector3d(previous_pose.xDirection[0], previous_pose.xDirection[1], previous_pose.xDirection[2])
rh_previous_yDirection = Rhino.Geometry.Vector3d(previous_pose.yDirection[0], previous_pose.yDirection[1], previous_pose.yDirection[2])
n_faces = all_poses_in_time.poses_per_element_dictionary[f"element_{i}"].n_faces
else:
rh_previous_xDirection = None
rh_previous_yDirection = None
n_faces = len(diffcheck_bindings.dfb_segmentation.DFSegmentation.segment_by_normal(df_cloud, 12, int(len(df_cloud.points)/20), True, int(len(df_cloud.points)/200), 1))

axes = df_cloud.get_principal_axes(n_faces)
for axe in axes:
vectors.append(Rhino.Geometry.Vector3d(axe[0], axe[1], axe[2]))

new_xDirection, new_yDirection = df_poses.select_vectors(vectors, rh_previous_xDirection, rh_previous_yDirection)

pose = df_poses.DFPose(
origin = [rh_point.X, rh_point.Y, rh_point.Z],
xDirection = [new_xDirection.X, new_xDirection.Y, new_xDirection.Z],
yDirection = [new_yDirection.X, new_yDirection.Y, new_yDirection.Z])
all_poses_this_time.append(pose)
plane = Rhino.Geometry.Plane(origin = rh_point, xDirection=new_xDirection, yDirection=new_yDirection)
planes.append(plane)

all_poses_in_time.add_step(all_poses_this_time)

return [planes, all_poses_in_time]
Binary file added src/gh/components/DF_main_pc_axes/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 60 additions & 0 deletions src/gh/components/DF_main_pc_axes/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"name": "DFPoseEstimation",
"nickname": "PoseEsimation",
"category": "diffCheck",
"subcategory": "PointCloud",
"description": "This compoment calculates the pose of a list of point clouds.",
"exposure": 4,
"instanceGuid": "22b0c6fc-bc16-4ff5-b789-e99776277f65",
"ghpython": {
"hideOutput": true,
"hideInput": true,
"isAdvancedMode": true,
"marshalOutGuids": true,
"iconDisplay": 2,
"inputParameters": [
{
"name": "i_clouds",
"nickname": "i_clouds",
"description": "clouds whose main axes are to be calculated",
"optional": true,
"allowTreeAccess": true,
"showTypeHints": true,
"scriptParamAccess": "list",
"wireDisplay": "default",
"sourceCount": 0,
"typeHintID": "pointcloud"
},
{
"name": "i_reset",
"nickname": "i_reset",
"description": "reset the history of the pose estimation",
"optional": true,
"allowTreeAccess": false,
"showTypeHints": true,
"scriptParamAccess": "item",
"wireDisplay": "default",
"sourceCount": 0,
"typeHintID": "bool"
}
],
"outputParameters": [
{
"name": "o_planes",
"nickname": "o_planes",
"description": "The resulting planes of the pose estimation in the last iteration.",
"optional": false,
"sourceCount": 0,
"graft": false
},
{
"name": "o_history",
"nickname": "o_history",
"description": "The history of poses of all the elements.",
"optional": false,
"sourceCount": 0,
"graft": false
}
]
}
}
118 changes: 118 additions & 0 deletions src/gh/diffCheck/diffCheck/df_poses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from scriptcontext import sticky as rh_sticky_dict
import json
from dataclasses import dataclass, field

@dataclass
class DFPose:
"""
This class represents the pose of a single element at a given time in the assembly process.
"""
origin: list
xDirection: list
yDirection: list

@dataclass
class DFPosesBeam:
"""
This class contains the poses of a single beam, at different times in the assembly process.
It also contains the number of faces detected for this element, based on which the poses are calculated.
"""
poses_dictionnary: dict
n_faces: int = 3

def add_pose(self, pose: DFPose, step_number: int):
"""
Add a pose to the dictionary of poses.
"""
self.poses_dictionnary[f"pose_{step_number}"] = pose

def set_n_faces(self, n_faces: int):
"""
Set the number of faces detected for this element.
"""
self.n_faces = n_faces

@dataclass
class DFPosesAssembly:
n_step: int = 0
poses_per_element_dictionary: dict = field(default_factory=lambda: rh_sticky_dict)

"""
This class contains the poses of the different elements of the assembly, at different times in the assembly process.
"""
def __post_init__(self):
"""
Initialize the poses_per_element_dictionary with empty DFPosesBeam objects.
"""
lengths = []
for element in self.poses_per_element_dictionary:
lengths.append(len(self.poses_per_element_dictionary[element].poses_dictionnary))
self.n_step = max(lengths) if lengths else 0

def add_step(self, new_poses: list[DFPose]):
for i, pose in enumerate(new_poses):
if f"element_{i}" not in self.poses_per_element_dictionary:
self.poses_per_element_dictionary[f"element_{i}"] = DFPosesBeam({}, 4)
for j in range(self.n_step):
self.poses_per_element_dictionary[f"element_{i}"].add_pose(None, j)
self.poses_per_element_dictionary[f"element_{i}"].add_pose(pose, self.n_step)
self.n_step += 1

def get_last_poses(self):
"""
Get the last poses of each element.
"""
if self.n_step == 0:
return None
last_poses = []
for i in range(len(self.poses_per_element_dictionary)):
last_poses.append(self.poses_per_element_dictionary[f"element_{i}"].poses_dictionnary[f"pose_{self.n_step-1}"])
return last_poses

def reset(self):
"""
Reset the assembly poses to the initial state.
"""
self.n_step = 0
rh_sticky_dict.clear()

def save(self, file_path: str):
"""
Save the assembly poses to a JSON file.
"""
with open(file_path, 'w') as f:
json.dump(self.poses_per_element_dictionary, f, default=lambda o: o.__dict__, indent=4)


def compute_dot_product(v1, v2):
"""
Compute the dot product of two vectors.
"""
return (v1.X * v2.X) + (v1.Y * v2.Y) + (v1.Z * v2.Z)


def select_vectors(vectors, previous_xDirection, previous_yDirection):
"""
Select the vectors that are aligned with the xDirection and yDirection.
"""
if previous_xDirection is not None and previous_yDirection is not None:
sorted_vectors_by_alignment = sorted(vectors, key=lambda v: compute_dot_product(v, previous_xDirection), reverse=True)
new_xDirection = sorted_vectors_by_alignment[0]
else:
new_xDirection = vectors[0]

condidates_for_yDirection = []
for v in vectors:
if compute_dot_product(v, new_xDirection) ** 2 < 0.5:
condidates_for_yDirection.append(v)
if previous_xDirection is not None and previous_yDirection is not None:
sorted_vectors_by_perpendicularity = sorted(condidates_for_yDirection, key=lambda v: compute_dot_product(v, previous_yDirection), reverse=True)
new_xDirection = sorted_vectors_by_alignment[0]
new_yDirection = sorted_vectors_by_perpendicularity[0] - compute_dot_product(sorted_vectors_by_perpendicularity[0], new_xDirection) * new_xDirection
new_yDirection.Unitize()
else:
new_xDirection = vectors[0]
sorted_vectors = sorted(vectors[1:], key=lambda v: compute_dot_product(v, new_xDirection)**2)
new_yDirection = sorted_vectors[0] - compute_dot_product(vectors[1], new_xDirection) * new_xDirection
new_yDirection.Unitize()
return new_xDirection, new_yDirection
10 changes: 9 additions & 1 deletion tests/unit_tests/DFPointCloudTest.cc
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,12 @@ TEST_F(DFPointCloudTestFixture, Transform) {

//-------------------------------------------------------------------------
// Others
//-------------------------------------------------------------------------
//-------------------------------------------------------------------------

TEST_F(DFPointCloudTestFixture, KMeansClusteringOfNormals) {
std::string path = diffCheck::io::GetTwoConnectedPlanesPlyPath();
diffCheck::geometry::DFPointCloud dfPointCloud2Planes;
dfPointCloud2Planes.LoadFromPLY(path);
std::vector<Eigen::Vector3d> axes = dfPointCloud2Planes.GetPrincipalAxes(2);
EXPECT_TRUE((axes[0] - Eigen::Vector3d(0, 0, 1)).norm() < 1e-2 || (axes[1] - Eigen::Vector3d(0, 0, 1)).norm() < 1e-2);
}
Loading