From 011d3962179f7e5ba2e4b61b7151ee6651c6f506 Mon Sep 17 00:00:00 2001 From: John Ingve Olsen Date: Mon, 25 Nov 2024 15:13:47 +0100 Subject: [PATCH] Implement experimental point cloud export API --- modules/_zivid/__init__.py | 1 + .../point_cloud_export/__init__.py | 3 + .../point_cloud_export/_export_frame.py | 50 +++++ .../point_cloud_export/file_format.py | 205 ++++++++++++++++++ setup.py | 8 +- src/CMakeLists.txt | 1 + src/PointCloudExport.cpp | 119 ++++++++++ src/Wrapper.cpp | 5 + src/include/ZividPython/PointCloudExport.h | 30 +++ test/experimental/test_point_cloud_export.py | 71 ++++++ 10 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 modules/zivid/experimental/point_cloud_export/__init__.py create mode 100644 modules/zivid/experimental/point_cloud_export/_export_frame.py create mode 100644 modules/zivid/experimental/point_cloud_export/file_format.py create mode 100644 src/PointCloudExport.cpp create mode 100644 src/include/ZividPython/PointCloudExport.h create mode 100644 test/experimental/test_point_cloud_export.py diff --git a/modules/_zivid/__init__.py b/modules/_zivid/__init__.py index 71b5e78e..c09e2e6a 100644 --- a/modules/_zivid/__init__.py +++ b/modules/_zivid/__init__.py @@ -54,6 +54,7 @@ Frame, FrameInfo, PointCloud, + point_cloud_export, Settings, version, Settings2D, diff --git a/modules/zivid/experimental/point_cloud_export/__init__.py b/modules/zivid/experimental/point_cloud_export/__init__.py new file mode 100644 index 00000000..a48355a4 --- /dev/null +++ b/modules/zivid/experimental/point_cloud_export/__init__.py @@ -0,0 +1,3 @@ +"""Module for exporting point cloud data to various formats. This API may change in the future.""" + +from zivid.experimental.point_cloud_export._export_frame import export_frame diff --git a/modules/zivid/experimental/point_cloud_export/_export_frame.py b/modules/zivid/experimental/point_cloud_export/_export_frame.py new file mode 100644 index 00000000..e50236ac --- /dev/null +++ b/modules/zivid/experimental/point_cloud_export/_export_frame.py @@ -0,0 +1,50 @@ +from zivid.frame import Frame +from zivid.experimental.point_cloud_export.file_format import ZDF, PLY, XYZ, PCD + +import _zivid + + +def export_frame(frame, file_format): + """Save frame to a file. + + The file format is specified by the file_format argument. The file format can be ZDF, PLY, XYZ, or PCD. + + If the format is PCD, this function stores the ordered point cloud with a header that indicates an unordered point + cloud. Since SDK 2.5, it is possible to export PCD with correct header by setting + `Configuration/APIBreakingBugFixes/FileFormats/PCD/UseOrganizedFormat` in Config.yml file. See + https://support.zivid.com/en/latest/reference-articles/point-cloud-structure-and-output-formats.html#organized-pcd-format. + + Args: + frame: Frame to export. + file_format: File format specification. + + Raises: + TypeError: If frame is not a Frame. + TypeError: If file_format is not a file format specification. + """ + if not isinstance(frame, Frame): + raise TypeError( + "Unsupported type for argument frame. Got {}, expected {}".format( + type(frame), Frame + ) + ) + if not any( + [ + isinstance(file_format, ZDF), + isinstance(file_format, PLY), + isinstance(file_format, XYZ), + isinstance(file_format, PCD), + ] + ): + raise TypeError( + "Unsupported type for argument file_format. Got {}, expected {}".format( + type(file_format), + " or ".join([t.__name__ for t in [ZDF, PLY, XYZ, PCD]]), + ) + ) + + format_impl_attr = f"_{type(file_format).__name__}__impl" + _zivid.point_cloud_export.export_frame( + frame._Frame__impl, # pylint: disable=protected-access + getattr(file_format, format_impl_attr), + ) diff --git a/modules/zivid/experimental/point_cloud_export/file_format.py b/modules/zivid/experimental/point_cloud_export/file_format.py new file mode 100644 index 00000000..be7f7e75 --- /dev/null +++ b/modules/zivid/experimental/point_cloud_export/file_format.py @@ -0,0 +1,205 @@ +"""Module defining file formats that point cloud data can be exported to.""" + +import _zivid + + +class ColorSpace: # pylint: disable=too-few-public-methods + """Color space for saving point cloud.""" + + linear_rgb = "linear_rgb" + srgb = "srgb" + + @staticmethod + def valid_values(): + """Get valid values for color space. + + Returns: + List of valid color spaces. + """ + return [ColorSpace.linear_rgb, ColorSpace.srgb] + + @classmethod + def _to_internal(cls, value): + if value == ColorSpace.linear_rgb: + return _zivid.point_cloud_export.ColorSpace.linear_rgb + if value == ColorSpace.srgb: + return _zivid.point_cloud_export.ColorSpace.srgb + raise ValueError( + "Invalid color space '{}'. Valid color spaces are: {}".format( + value, cls.valid_values() + ) + ) + + +class ZDF: # pylint: disable=too-few-public-methods + """Specification for saving frame in ZDF (*.zdf) format.""" + + def __init__(self, file_name): + """Create a ZDF file format specification with file name. + + Args: + file_name: File name. + + Raises: + TypeError: If file_name is not a string. + """ + if not isinstance(file_name, str): + raise TypeError( + "Unsupported type for argument file_name. Got {}, expected {}".format( + type(file_name), str + ) + ) + self.__impl = _zivid.point_cloud_export.file_format.ZDF(file_name) + + def __str__(self): + return str(self.__impl) + + +class PLY: # pylint: disable=too-few-public-methods + """Specification for saving frame in PLY (*.ply) format. + + PLY is a file format developed at Stanford. To learn more about the PLY file format, + see https://paulbourke.net/dataformats/ply/. + """ + + class Layout: + """Layout for saving point cloud.""" + + ordered = "ordered" + unordered = "unordered" + + @staticmethod + def valid_values(): + """Get valid values for layout. + + Returns: + List of valid layouts. + """ + return [PLY.Layout.ordered, PLY.Layout.unordered] + + @classmethod + def _to_internal(cls, value): + if value == PLY.Layout.ordered: + return _zivid.point_cloud_export.file_format.PLY.Layout.ordered + if value == PLY.Layout.unordered: + return _zivid.point_cloud_export.file_format.PLY.Layout.unordered + raise ValueError( + "Invalid layout '{}'. Valid layouts are: {}".format( + value, cls.valid_values() + ) + ) + + def __init__(self, file_name, layout=Layout.ordered, color_space=ColorSpace.srgb): + """Create a PLY file format specification with file name. + + Args: + file_name: File name. + layout: Layout of point cloud. Default is ordered. + color_space: Color space of point cloud. Default is sRGB. + + Raises: + TypeError: If file_name, layout, or color_space are not strings. + """ + if not isinstance(file_name, str): + raise TypeError( + "Unsupported type for argument file_name. Got {}, expected {}".format( + type(file_name), str + ) + ) + if not isinstance(layout, str): + raise TypeError( + "Unsupported type for argument layout. Got {}, expected {}".format( + type(layout), str + ) + ) + if not isinstance(color_space, str): + raise TypeError( + "Unsupported type for argument color_space. Got {}, expected {}".format( + type(color_space), str + ) + ) + self.__impl = _zivid.point_cloud_export.file_format.PLY( + file_name, + PLY.Layout._to_internal(layout), + ColorSpace._to_internal(color_space), + ) + + def __str__(self): + return str(self.__impl) + + +class XYZ: # pylint: disable=too-few-public-methods + """Specification for saving frame in ASCII (*.xyz) format. + + ASCII characters are used to store cartesian coordinates of XYZ points and RGB color values. + """ + + def __init__(self, file_name, color_space=ColorSpace.srgb): + """Create a XYZ file format specification with file name. + + Sets color space to linear RGB. + + Args: + file_name: File name. + color_space: Color space of point cloud. Default is sRGB. + + Raises: + TypeError: If file_name or color_space are not strings. + """ + if not isinstance(file_name, str): + raise TypeError( + "Unsupported type for argument file_name. Got {}, expected {}".format( + type(file_name), str + ) + ) + if not isinstance(color_space, str): + raise TypeError( + "Unsupported type for argument color_space. Got {}, expected {}".format( + type(color_space), str + ) + ) + self.__impl = _zivid.point_cloud_export.file_format.XYZ( + file_name, ColorSpace._to_internal(color_space) + ) + + def __str__(self): + return str(self.__impl) + + +class PCD: # pylint: disable=too-few-public-methods + """Specification for saving frame in PCD (*.pcd) format. + + PCD is a file format native to the Point Cloud Library (PCL). To learn more about + the PCD file format, see + https://pcl.readthedocs.io/projects/tutorials/en/latest/pcd_file_format.html#pcd-file-format. + """ + + def __init__(self, file_name, color_space=ColorSpace.srgb): + """Create a PCD file format specification with file name. + + Args: + file_name: File name. + color_space: Color space of point cloud. Default is sRGB. + + Raises: + TypeError: If file_name or color_space are not strings. + """ + if not isinstance(file_name, str): + raise TypeError( + "Unsupported type for argument file_name. Got {}, expected {}".format( + type(file_name), str + ) + ) + if not isinstance(color_space, str): + raise TypeError( + "Unsupported type for argument color_space. Got {}, expected {}".format( + type(color_space), str + ) + ) + self.__impl = _zivid.point_cloud_export.file_format.PCD( + file_name, + ColorSpace._to_internal(color_space), + ) + + def __str__(self): + return str(self.__impl) diff --git a/setup.py b/setup.py index ec9e630c..0c2e6a9c 100644 --- a/setup.py +++ b/setup.py @@ -135,7 +135,13 @@ def _main(): author="Zivid AS", author_email="customersuccess@zivid.com", license="BSD 3-Clause", - packages=["zivid", "zivid._calibration", "zivid.experimental", "_zivid"], + packages=[ + "zivid", + "zivid._calibration", + "zivid.experimental", + "zivid.experimental.point_cloud_export", + "_zivid", + ], package_dir={"": "modules"}, install_requires=["numpy"], cmake_args=[ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 83e8f901..a0f62724 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,6 +12,7 @@ set(SOURCES InfieldCorrection/InfieldCorrection.cpp NodeType.cpp PixelMapping.cpp + PointCloudExport.cpp Projection.cpp Presets.cpp ReleasableArray2D.cpp diff --git a/src/PointCloudExport.cpp b/src/PointCloudExport.cpp new file mode 100644 index 00000000..86366adf --- /dev/null +++ b/src/PointCloudExport.cpp @@ -0,0 +1,119 @@ +#include + +#include + +#include + +namespace py = pybind11; + +namespace +{ + template + auto wrapFileFormat(py::class_ pyClass) + { + return pyClass.def(py::init(), py::arg("file_name")) + .def("to_string", &FileFormat::toString); + } + + template + auto exportFrame(const ZividPython::ReleasableFrame &frame, const FileFormat &fileFormat) + { + return Zivid::Experimental::PointCloudExport::exportFrame(frame.impl(), fileFormat); + } +} // namespace + +namespace ZividPython +{ + void wrapEnum(py::enum_ pyEnum) + { + pyEnum.value("srgb", Zivid::Experimental::PointCloudExport::ColorSpace::sRGB) + .value("linear_rgb", Zivid::Experimental::PointCloudExport::ColorSpace::linearRGB) + .export_values(); + } + + void wrapClass(py::class_ pyClass) + { + wrapFileFormat(std::move(pyClass)); + } + + void wrapClass(py::class_ pyClass) + { + using Layout = Zivid::Experimental::PointCloudExport::FileFormat::PLY::Layout; + ZIVID_PYTHON_WRAP_ENUM_CLASS(pyClass, Layout); + + wrapFileFormat(std::move(pyClass)) + .def(py::init(), + py::arg("file_name"), + py::arg("layout"), + py::arg("color_space")); + } + + void wrapEnum(py::enum_ pyEnum) + { + pyEnum.value("ordered", Zivid::Experimental::PointCloudExport::FileFormat::PLY::Layout::ordered) + .value("unordered", Zivid::Experimental::PointCloudExport::FileFormat::PLY::Layout::unordered) + .export_values(); + } + + void wrapClass(py::class_ pyClass) + { + wrapFileFormat(std::move(pyClass)) + .def(py::init(), + py::arg("file_name"), + py::arg("color_space")); + } + + void wrapClass(py::class_ pyClass) + { + wrapFileFormat(std::move(pyClass)) + .def(py::init(), + py::arg("file_name"), + py::arg("color_space")); + } + + namespace PointCloudExport + { + namespace FileFormat + { + void wrapAsSubmodule(py::module &dest) + { + using ZDF = Zivid::Experimental::PointCloudExport::FileFormat::ZDF; + ZIVID_PYTHON_WRAP_CLASS(dest, ZDF); + + using PLY = Zivid::Experimental::PointCloudExport::FileFormat::PLY; + ZIVID_PYTHON_WRAP_CLASS(dest, PLY); + + using XYZ = Zivid::Experimental::PointCloudExport::FileFormat::XYZ; + ZIVID_PYTHON_WRAP_CLASS(dest, XYZ); + + using PCD = Zivid::Experimental::PointCloudExport::FileFormat::PCD; + ZIVID_PYTHON_WRAP_CLASS(dest, PCD); + } + } // namespace FileFormat + + void wrapAsSubmodule(py::module &dest) + { + using ColorSpace = Zivid::Experimental::PointCloudExport::ColorSpace; + ZIVID_PYTHON_WRAP_ENUM_CLASS(dest, ColorSpace); + + wrapNamespaceAsSubmodule(dest, FileFormat::wrapAsSubmodule, "FileFormat"); + + dest.def("export_frame", + py::overload_cast( + &exportFrame)) + .def("export_frame", + py::overload_cast( + &exportFrame)) + .def("export_frame", + py::overload_cast( + &exportFrame)) + .def("export_frame", + py::overload_cast( + &exportFrame)); + } + } // namespace PointCloudExport +} // namespace ZividPython \ No newline at end of file diff --git a/src/Wrapper.cpp b/src/Wrapper.cpp index dac42240..68469654 100644 --- a/src/Wrapper.cpp +++ b/src/Wrapper.cpp @@ -22,6 +22,8 @@ #include #include +#include +#include #include ZIVID_PYTHON_MODULE // NOLINT @@ -74,4 +76,7 @@ ZIVID_PYTHON_MODULE // NOLINT using PixelMapping = Zivid::Experimental::PixelMapping; ZIVID_PYTHON_WRAP_CLASS(module, PixelMapping); + + namespace PointCloudExport = Zivid::Experimental::PointCloudExport; + ZIVID_PYTHON_WRAP_NAMESPACE_AS_SUBMODULE(module, PointCloudExport); } diff --git a/src/include/ZividPython/PointCloudExport.h b/src/include/ZividPython/PointCloudExport.h new file mode 100644 index 00000000..ec0dcabc --- /dev/null +++ b/src/include/ZividPython/PointCloudExport.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include + +namespace ZividPython +{ + void wrapEnum(pybind11::enum_ pyEnum); + + void wrapClass(pybind11::class_ pyClass); + + void wrapClass(pybind11::class_ pyClass); + void wrapEnum(pybind11::enum_ pyEnum); + + void wrapClass(pybind11::class_ pyClass); + + void wrapClass(pybind11::class_ pyClass); + + namespace PointCloudExport + { + namespace FileFormat + { + void wrapAsSubmodule(pybind11::module &dest); + } // namespace FileFormat + + void wrapAsSubmodule(pybind11::module &dest); + } // namespace PointCloudExport +} // namespace ZividPython diff --git a/test/experimental/test_point_cloud_export.py b/test/experimental/test_point_cloud_export.py new file mode 100644 index 00000000..a15549e9 --- /dev/null +++ b/test/experimental/test_point_cloud_export.py @@ -0,0 +1,71 @@ +import os +import tempfile +from zivid import Frame +from zivid.experimental.point_cloud_export.file_format import ( + ZDF, + PLY, + ColorSpace, + XYZ, + PCD, +) +from zivid.experimental.point_cloud_export import export_frame + + +def test_point_cloud_export_as_zdf(frame): + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "frame.zdf") + export_frame(frame, ZDF(filepath)) + assert os.path.exists(filepath) + loaded_frame = Frame(filepath) + assert frame.info == loaded_frame.info + + +def test_point_cloud_export_as_ply_ordered_srgb(frame): + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "frame.ply") + export_frame( + frame, PLY(filepath, layout=PLY.Layout.ordered, color_space=ColorSpace.srgb) + ) + assert os.path.exists(filepath) + + +def test_point_cloud_export_as_ply_unordered_linear(frame): + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "frame_unordered.ply") + export_frame( + frame, + PLY( + filepath, + layout=PLY.Layout.unordered, + color_space=ColorSpace.linear_rgb, + ), + ) + assert os.path.exists(filepath) + + +def test_point_cloud_export_as_xyz_srgb(frame): + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "frame.xyz") + export_frame(frame, XYZ(filepath, color_space=ColorSpace.srgb)) + assert os.path.exists(filepath) + + +def test_point_cloud_export_as_xyz_linear(frame): + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "frame_linear.xyz") + export_frame(frame, XYZ(filepath, color_space=ColorSpace.linear_rgb)) + assert os.path.exists(filepath) + + +def test_point_cloud_export_as_pcd_srgb(frame): + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "frame.pcd") + export_frame(frame, PCD(filepath, color_space=ColorSpace.srgb)) + assert os.path.exists(filepath) + + +def test_point_cloud_export_as_pcd_linear(frame): + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "frame_linear.pcd") + export_frame(frame, PCD(filepath, color_space=ColorSpace.linear_rgb)) + assert os.path.exists(filepath)