Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## Main
- Fix appending tensor point clouds when either operand has no declared attributes (issue #7091).
- Add point cloud smoothing algorithms: Moving Least Squares (MLS), Laplacian, Taubin, and bilateral smoothing. These methods provide flexible noise reduction for point clouds with different preservation characteristics (PR #7419).
- Add vcpkg support for easier dependency management (PR #7386)
- Exposed advanced parameters (`full_depth`, `samples_per_node`, `point_weight`) for Poisson surface reconstruction in `TriangleMesh.create_from_point_cloud_poisson` (PR #7430) (issue #7248)
Expand Down
9 changes: 9 additions & 0 deletions cpp/open3d/t/geometry/PointCloud.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,15 @@ PointCloud PointCloud::To(const core::Device& device, bool copy) const {
PointCloud PointCloud::Clone() const { return To(GetDevice(), /*copy=*/true); }

PointCloud PointCloud::Append(const PointCloud& other) const {
// A PointCloud with no declared attributes has no dtype or shape to
// validate. Treat it as the identity and adopt the populated schema.
if (point_attr_.empty()) {
return other.point_attr_.empty() ? Clone() : other.Clone();
}
if (other.point_attr_.empty()) {
return Clone();
}

PointCloud pcd(GetDevice());

int64_t length = GetPointPositions().GetLength();
Expand Down
5 changes: 5 additions & 0 deletions cpp/open3d/t/geometry/PointCloud.h
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ class PointCloud : public Geometry, public DrawableGeometry {

/// Append a point cloud and returns the resulting point cloud.
///
/// A point cloud with no declared attributes acts as the identity on either
/// side. If exactly one operand has attributes, the result adopts that
/// operand's attributes, dtype and device. If both are empty, the result
/// retains this point cloud's device.
///
/// The point cloud being appended, must have all the attributes
/// present in the point cloud it is being appended to, with same
/// dtype, device and same shape other than the first dimension / length.
Expand Down
20 changes: 16 additions & 4 deletions cpp/pybind/t/geometry/pointcloud.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,22 @@ void pybind_pointcloud_definitions(py::module& m) {
pointcloud.def("get_center", &PointCloud::GetCenter,
"Returns the center for point coordinates.");

pointcloud.def("append",
[](const PointCloud& self, const PointCloud& other) {
return self.Append(other);
});
pointcloud.def(
"append",
[](const PointCloud& self, const PointCloud& other) {
return self.Append(other);
},
R"(Append another point cloud and return the result.

A point cloud with no declared attributes acts as the identity on either side,
which allows incremental accumulation to start from an empty point cloud. If
both point clouds have no attributes, the result keeps the first point cloud's
device.

Example:
accumulated = o3d.t.geometry.PointCloud()
accumulated = accumulated.append(frame))",
"other"_a);
pointcloud.def("__add__",
[](const PointCloud& self, const PointCloud& other) {
return self.Append(other);
Expand Down
31 changes: 31 additions & 0 deletions cpp/tests/t/geometry/PointCloud.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,37 @@ TEST_P(PointCloudPermuteDevices, Append) {
// pcd2 has an extra attribute "labels" which is missing in pcd, therefore
// adding pcd to pcd2 will throw an error for missing attribute "labels"
EXPECT_ANY_THROW(pcd2 + pcd);

// A point cloud without any declared attributes acts as the identity. The
// populated side's full schema is preserved in either operand order.
// Keep the schema-less operand on its default CPU device. Accelerator
// parametrizations verify that identity append adopts the populated side.
t::geometry::PointCloud empty;
t::geometry::PointCloud left_identity = empty + pcd2;
EXPECT_TRUE(left_identity.GetPointPositions().AllClose(points));
EXPECT_TRUE(left_identity.GetPointColors().AllClose(colors));
EXPECT_TRUE(left_identity.GetPointAttr("labels").AllClose(labels));
EXPECT_EQ(left_identity.GetPointPositions().GetDtype(), dtype);
EXPECT_EQ(left_identity.GetDevice(), device);
EXPECT_FALSE(
left_identity.GetPointPositions().IsSame(pcd2.GetPointPositions()));

t::geometry::PointCloud right_identity = pcd + empty;
EXPECT_TRUE(right_identity.GetPointPositions().AllClose(points));
EXPECT_TRUE(right_identity.GetPointColors().AllClose(colors));
EXPECT_EQ(right_identity.GetPointPositions().GetDtype(), dtype);
EXPECT_EQ(right_identity.GetDevice(), device);
EXPECT_FALSE(
right_identity.GetPointPositions().IsSame(pcd.GetPointPositions()));

t::geometry::PointCloud empty_on_device(device);
t::geometry::PointCloud empty_result = empty_on_device + empty;
EXPECT_TRUE(empty_result.IsEmpty());
EXPECT_EQ(empty_result.GetDevice(), device);

// Appending clones the populated operand instead of aliasing its tensors.
pcd2.GetPointPositions().Fill(2);
EXPECT_TRUE(left_identity.GetPointPositions().AllClose(points));
}

TEST_P(PointCloudPermuteDevices, Has) {
Expand Down
32 changes: 32 additions & 0 deletions python/test/t/geometry/test_pointcloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ def test_member_functions(device):
pcd3 = pcd2 + pcd
assert 'The pointcloud is missing attribute' in str(excinfo.value)

# A point cloud without declared attributes is the identity for append.
# Leave the schema-less operand on the default CPU device. Accelerator
# parametrizations verify that append adopts the populated side's device.
empty = o3d.t.geometry.PointCloud()
left_identity = empty.append(pcd2)
assert left_identity.point.positions.allclose(
o3c.Tensor.ones((2, 3), dtype, device))
assert left_identity.point.normals.allclose(
o3c.Tensor.ones((2, 3), dtype, device))
assert left_identity.point.labels.allclose(
o3c.Tensor.ones((2, 3), dtype, device))
assert left_identity.point.positions.dtype == dtype
assert left_identity.device == device

right_identity = pcd.append(empty)
assert right_identity.point.positions.allclose(
o3c.Tensor.ones((2, 3), dtype, device))
assert right_identity.point.normals.allclose(
o3c.Tensor.ones((2, 3), dtype, device))
assert right_identity.point.positions.dtype == dtype
assert right_identity.device == device

empty_on_device = o3d.t.geometry.PointCloud(device)
empty_result = empty_on_device.append(empty)
assert "positions" not in empty_result.point
assert empty_result.device == device

# The result must not alias the populated operand's tensors.
pcd2.point.positions[:] = 2
assert left_identity.point.positions.allclose(
o3c.Tensor.ones((2, 3), dtype, device))

# transform.
pcd = o3d.t.geometry.PointCloud(device)
transform_t = o3c.Tensor(
Expand Down