diff --git a/README.rst b/README.rst index db6dc76..f0ea23d 100644 --- a/README.rst +++ b/README.rst @@ -54,3 +54,59 @@ With the following concise code: new_cloud = cloud.get_sample("voxelgrid_nearest", voxelgrid_id=voxelgrid_id, as_PyntCloud=True) new_cloud.to_file("out_file.npz") + +Integration with other libraries +================================ + +pyntcloud offers seamless integration with other 3D processing libraries. + +You can create / convert PyntCloud instances from / to many 3D processing libraries using the `from_instance` / `to_instance` methods: + +- `Open3D `_ + +.. code-block:: python + + import open3d as o3d + from pyntcloud import PyntCloud + + # FROM Open3D + original_triangle_mesh = o3d.io.read_triangle_mesh("diamond.ply") + cloud = PyntCloud.from_instance("open3d", original_triangle_mesh) + assert cloud.mesh is not None + assert np.allclose(cloud.mesh.values, original_triangle_mesh.triangles) + assert np.allclose(cloud.xyz, original_triangle_mesh.vertices) + assert {'red', 'green', 'blue'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['red', 'green', 'blue']].values / 255., original_triangle_mesh.vertex_colors) + assert {'nx', 'ny', 'nz'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['nx', 'ny', 'nz']].values, original_triangle_mesh.vertex_normals) + + # TO Open3D + cloud = PyntCloud.from_file("diamond.ply") + converted_triangle_mesh = cloud.to_instance("open3d", mesh=True) # mesh=True by default + assert isinstance(converted_triangle_mesh, o3d.geometry.TriangleMesh) + assert np.allclose(cloud.xyz, converted_triangle_mesh.vertices) + assert np.allclose(cloud.mesh.values, converted_triangle_mesh.triangles) + +- `PyVista `_ + +.. code-block:: python + + import pyvista as pv + from pyntcloud import PyntCloud + + # FROM PyVista + original_point_cloud = pv.read("diamond.ply") + cloud = PyntCloud.from_instance("pyvista", original_point_cloud) + assert np.allclose(cloud.xyz, original_point_cloud.points) + assert {'red', 'green', 'blue'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['red', 'green', 'blue']].values, original_point_cloud.point_arrays["RGB"]) + assert {'nx', 'ny', 'nz'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['nx', 'ny', 'nz']].values, original_point_cloud.point_arrays["Normals"]) + + # TO PyVista + cloud = PyntCloud.from_file("diamond.ply") + converted_triangle_mesh = cloud.to_instance("open3d", mesh=True) + assert isinstance(converted_triangle_mesh, pv.PolyData) + assert np.allclose(cloud.xyz, converted_triangle_mesh.points) + assert np.allclose(cloud.mesh.values, converted_triangle_mesh.faces[:, 1:]) + diff --git a/docs/conf.py b/docs/conf.py index 07e1e7b..1044661 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -67,9 +67,9 @@ # built documents. # # The short X.Y version. -version = '0.0.1' +version = '0.0.2' # The full version, including alpha/beta/rc tags. -release = '0.0.1' +release = '0.0.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/pyntcloud/core_class.py b/pyntcloud/core_class.py index 945e58c..bef493a 100644 --- a/pyntcloud/core_class.py +++ b/pyntcloud/core_class.py @@ -131,7 +131,7 @@ def from_file(cls, filename, **kwargs): @classmethod def from_instance(cls, library, instance, **kwargs): - """Extract data from file and construct a PyntCloud with it. + """Convert library's instance to PyntCloud intstance. Parameters ---------- @@ -182,7 +182,7 @@ def to_file(self, filename, also_save=None, **kwargs): TO_FILE[ext](**kwargs) def to_instance(self, library, **kwargs): - """Save PyntCloud data to file. + """Convert PyntCloud's instance to library's instance. Parameters ---------- diff --git a/pyntcloud/io/__init__.py b/pyntcloud/io/__init__.py index 164e551..6211a97 100644 --- a/pyntcloud/io/__init__.py +++ b/pyntcloud/io/__init__.py @@ -1,3 +1,4 @@ +from pyntcloud.io.open3d import from_open3d, to_open3d from pyntcloud.io.pyvista import from_pyvista, to_pyvista from .ascii import read_ascii, write_ascii from .bin import read_bin, write_bin @@ -23,7 +24,8 @@ "XYZ": read_ascii, } FROM_INSTANCE = { - "PYVISTA": from_pyvista + "PYVISTA": from_pyvista, + "OPEN3D": from_open3d } TO_FILE = { @@ -38,5 +40,6 @@ "XYZ": write_ascii, } TO_INSTANCE = { - "PYVISTA": to_pyvista + "PYVISTA": to_pyvista, + "OPEN3D": to_open3d } diff --git a/pyntcloud/io/open3d.py b/pyntcloud/io/open3d.py new file mode 100644 index 0000000..96c0492 --- /dev/null +++ b/pyntcloud/io/open3d.py @@ -0,0 +1,85 @@ +import numpy as np +import pandas as pd + + +def from_open3d(o3d_data, **kwargs): + """Create a PyntCloud instance from Open3D's PointCloud/TriangleMesh instance""" + try: + import open3d as o3d + except ImportError: + raise ImportError("Open3D must be installed. Try `pip install open3d`") + + if not isinstance(o3d_data, (o3d.geometry.PointCloud, o3d.geometry.TriangleMesh)): + raise TypeError(f"Type {type(o3d_data)} not supported for conversion." + f"Expected {o3d.geometry.PointCloud} or {o3d.geometry.TriangleMesh}") + + mesh = None + if isinstance(o3d_data, o3d.geometry.TriangleMesh): + mesh = pd.DataFrame(data=np.asarray(o3d_data.triangles), + columns=['v1', 'v2', 'v3']) + + points = pd.DataFrame(data=np.asarray(o3d_data.vertices), + columns=["x", "y", "z"]) + + if o3d_data.vertex_colors: + colors = (np.asarray(o3d_data.vertex_colors) * 255).astype(np.uint8) + points["red"] = colors[:, 0] + points["green"] = colors[:, 1] + points["blue"] = colors[:, 2] + + if o3d_data.vertex_normals: + normals = np.asarray(o3d_data.vertex_normals) + points["nx"] = normals[:, 0] + points["ny"] = normals[:, 1] + points["nz"] = normals[:, 2] + + elif isinstance(o3d_data, o3d.geometry.PointCloud): + points = pd.DataFrame(data=np.asarray(o3d_data.points), + columns=["x", "y", "z"]) + + if o3d_data.colors: + colors = (np.asarray(o3d_data.colors) * 255).astype(np.uint8) + points["red"] = colors[:, 0] + points["green"] = colors[:, 1] + points["blue"] = colors[:, 2] + + if o3d_data.normals: + normals = np.asarray(o3d_data.normals) + points["nx"] = normals[:, 0] + points["ny"] = normals[:, 1] + points["nz"] = normals[:, 2] + + return { + "points": points, + "mesh": mesh + } + + +def to_open3d(cloud, + mesh=True, + colors=True, + normals=True, + **kwargs): + """Convert PyntCloud's instance `cloud` to Open3D's PointCloud/TriangleMesh instance""" + try: + import open3d as o3d + except ImportError: + raise ImportError("Open3D must be installed. Try `pip install open3d`") + + if mesh and cloud.mesh is not None: + triangle_mesh = o3d.geometry.TriangleMesh() + triangle_mesh.triangles = o3d.utility.Vector3iVector(cloud.mesh[["v1", "v2", "v3"]].values) + triangle_mesh.vertices = o3d.utility.Vector3dVector(cloud.xyz) + if colors and {'red', 'green', 'blue'}.issubset(cloud.points.columns): + triangle_mesh.vertex_colors = o3d.utility.Vector3dVector(cloud.points[['red', 'green', 'blue']].values) + if normals and {'nx', 'ny', 'nz'}.issubset(cloud.points.columns): + triangle_mesh.vertex_normals = o3d.utility.Vector3dVector(cloud.points[['nx', 'ny', 'nz']].values) + return triangle_mesh + else: + point_cloud = o3d.geometry.PointCloud() + point_cloud.points = o3d.utility.Vector3dVector(cloud.xyz) + if colors and {'red', 'green', 'blue'}.issubset(cloud.points.columns): + point_cloud.colors = o3d.utility.Vector3dVector(cloud.points[['red', 'green', 'blue']].values) + if normals and {'nx', 'ny', 'nz'}.issubset(cloud.points.columns): + point_cloud.normals = o3d.utility.Vector3dVector(cloud.points[['nx', 'ny', 'nz']].values) + return point_cloud diff --git a/requirements.txt b/requirements.txt index 84ea37d..c257b06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ matplotlib numba pytest pyvista +open3d diff --git a/tests/integration/io/test_from_instance.py b/tests/integration/io/test_from_instance.py new file mode 100644 index 0000000..337c9dc --- /dev/null +++ b/tests/integration/io/test_from_instance.py @@ -0,0 +1,82 @@ +import pytest +import numpy as np +from pyntcloud import PyntCloud + +try: + import pyvista as pv + SKIP_PYVISTA = False +except: + pv = None + SKIP_PYVISTA = True + +try: + import open3d as o3d + SKIP_OPEN3D = False +except: + o3d = None + SKIP_OPEN3D = True + + +@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") +def test_pyvista_conversion(data_path): + original_point_cloud = pv.read(str(data_path / "diamond.ply")) + cloud = PyntCloud.from_instance("pyvista", original_point_cloud) + assert np.allclose(cloud.xyz, original_point_cloud.points) + assert {'red', 'green', 'blue'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['red', 'green', 'blue']].values, original_point_cloud.point_arrays["RGB"]) + assert {'nx', 'ny', 'nz'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['nx', 'ny', 'nz']].values, original_point_cloud.point_arrays["Normals"]) + + +@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") +def test_pyvista_normals_are_handled(): + poly = pv.Sphere() + pc = PyntCloud.from_instance("pyvista", poly) + assert all(x in pc.points.columns for x in ["nx", "ny", "nz"]) + + +@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") +def test_pyvista_multicomponent_scalars_are_splitted(): + poly = pv.Sphere() + poly.point_arrays["foo"] = np.zeros_like(poly.points) + pc = PyntCloud.from_instance("pyvista", poly) + assert all(x in pc.points.columns for x in ["foo_0", "foo_1", "foo_2"]) + + +@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") +def test_pyvista_rgb_is_handled(): + """ Serves as regression test for old `in` behaviour that could cause a subtle bug + if poin_arrays contain a field with `name in "RGB"` + """ + poly = pv.Sphere() + poly.point_arrays["RG"] = np.zeros_like(poly.points)[:, :2] + pc = PyntCloud.from_instance("pyvista", poly) + assert all(x in pc.points.columns for x in ["RG_0", "RG_1"]) + + +@pytest.mark.skipif(SKIP_OPEN3D, reason="Requires Open3D") +def test_open3d_point_cloud(data_path): + point_cloud = o3d.io.read_point_cloud(str(data_path.joinpath("diamond.ply"))) + cloud = PyntCloud.from_instance("open3d", point_cloud) + assert np.allclose(cloud.xyz, np.asarray(point_cloud.points)) + assert {'red', 'green', 'blue'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['red', 'green', 'blue']].values / 255., np.asarray(point_cloud.colors)) + + assert {'nx', 'ny', 'nz'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['nx', 'ny', 'nz']].values, np.asarray(point_cloud.normals)) + + +@pytest.mark.skipif(SKIP_OPEN3D, reason="Requires Open3D") +def test_open3d_triangle_mesh(data_path): + triangle_mesh = o3d.io.read_triangle_mesh(str(data_path.joinpath("diamond.ply"))) + cloud = PyntCloud.from_instance("open3d", triangle_mesh) + assert cloud.mesh is not None + assert np.allclose(cloud.mesh.values, triangle_mesh.triangles) + + assert np.allclose(cloud.xyz, triangle_mesh.vertices) + + assert {'red', 'green', 'blue'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['red', 'green', 'blue']].values / 255., triangle_mesh.vertex_colors) + + assert {'nx', 'ny', 'nz'}.issubset(cloud.points.columns) + assert np.allclose(cloud.points[['nx', 'ny', 'nz']].values, triangle_mesh.vertex_normals) diff --git a/tests/integration/io/test_to_instance.py b/tests/integration/io/test_to_instance.py new file mode 100644 index 0000000..349590d --- /dev/null +++ b/tests/integration/io/test_to_instance.py @@ -0,0 +1,43 @@ +import pytest +import numpy as np +from pyntcloud import PyntCloud + +try: + import pyvista as pv + SKIP_PYVISTA = False +except: + pv = None + SKIP_PYVISTA = True + +try: + import open3d as o3d + SKIP_OPEN3D = False +except: + o3d = None + SKIP_OPEN3D = True + + +@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") +def test_pyvista_conversion(data_path): + cloud = PyntCloud.from_file(str(data_path.joinpath("diamond.ply"))) + poly = cloud.to_instance("pyvista", mesh=True) + assert np.allclose(cloud.xyz, poly.points) + assert np.allclose(cloud.mesh.values, poly.faces[:, 1:]) + + +@pytest.mark.skipif(SKIP_OPEN3D, reason="Requires Open3D") +def test_open3d_point_cloud_conversion(data_path): + cloud = PyntCloud.from_file(str(data_path.joinpath("diamond.ply"))) + point_cloud = cloud.to_instance("open3d", mesh=False) + assert isinstance(point_cloud, o3d.geometry.PointCloud) + assert np.allclose(cloud.xyz, point_cloud.points) + + +@pytest.mark.skipif(SKIP_OPEN3D, reason="Requires Open3D") +def test_open3d_triangle_mesh_conversion(data_path): + cloud = PyntCloud.from_file(str(data_path.joinpath("diamond.ply"))) + # mesh=True by default + triangle_mesh = cloud.to_instance("open3d") + assert isinstance(triangle_mesh, o3d.geometry.TriangleMesh) + assert np.allclose(cloud.xyz, triangle_mesh.vertices) + assert np.allclose(cloud.mesh.values, triangle_mesh.triangles) \ No newline at end of file diff --git a/tests/integration/test_core_class.py b/tests/integration/test_core_class.py index 0f32417..88389c7 100644 --- a/tests/integration/test_core_class.py +++ b/tests/integration/test_core_class.py @@ -1,17 +1,9 @@ -import os import pytest import numpy as np import pandas as pd from shutil import rmtree from pyntcloud import PyntCloud -try: - import pyvista as pv - SKIP_PYVISTA = False -except: - pv = None - SKIP_PYVISTA = True - def test_points(): """PyntCloud.points. @@ -99,43 +91,3 @@ def test_split_on(data_path): assert len(output) == 8 rmtree("tmp_out") - - -@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") -def test_pyvista_conversion(data_path): - print(data_path) - print(data_path.joinpath("diamond.ply")) - cloud = PyntCloud.from_file(str(data_path.joinpath("diamond.ply"))) - poly = cloud.to_instance("pyvista", mesh=True) - pc = PyntCloud.from_instance("pyvista", poly) - assert np.allclose(cloud.points[['x', 'y', 'z']].values, poly.points) - assert np.allclose(cloud.mesh.values, pc.mesh.values) - poly = pv.read(str(data_path.joinpath("diamond.ply"))) - pc = PyntCloud.from_instance("pyvista", poly) - assert np.allclose(pc.points[['x', 'y', 'z']].values, poly.points) - - -@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") -def test_pyvista_normals_are_handled(): - poly = pv.Sphere() - pc = PyntCloud.from_instance("pyvista", poly) - assert all(x in pc.points.columns for x in ["nx", "ny", "nz"]) - - -@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") -def test_pyvista_multicomponent_scalars_are_splitted(): - poly = pv.Sphere() - poly.point_arrays["foo"] = np.zeros_like(poly.points) - pc = PyntCloud.from_instance("pyvista", poly) - assert all(x in pc.points.columns for x in ["foo_0", "foo_1", "foo_2"]) - - -@pytest.mark.skipif(SKIP_PYVISTA, reason="Requires PyVista") -def test_pyvista_RGB_is_handled(): - """ Serves as regression test for old `in` behaviour that could cause a subtle bug - if poin_arrays contain a field with `name in "RGB"` - """ - poly = pv.Sphere() - poly.point_arrays["RG"] = np.zeros_like(poly.points)[:, :2] - pc = PyntCloud.from_instance("pyvista", poly) - assert all(x in pc.points.columns for x in ["RG_0", "RG_1"])