From b73f7fdb7858407b69cca8d4e5eeb4aacddad214 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Fri, 4 Oct 2024 15:00:12 -0500 Subject: [PATCH 01/10] update readme to include developer build instructions (#180) --- README.rst | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0132ec84..9b6e123f 100644 --- a/README.rst +++ b/README.rst @@ -20,6 +20,21 @@ PDAL Python support is installable via PyPI: pip install PDAL + +Developers can control many settings including debug builds and where the libraries are installed +using `scikit-build-core `_ settings: + +.. code-block:: + + python -m pip install \ + -Cbuild-dir=build \ + -e \ + . \ + --config-settings=cmake.build-type="Debug" \ + -vv \ + --no-deps \ + --no-build-isolation + GitHub ................................................................................ @@ -168,7 +183,7 @@ PDAL and Python: print(len(intensity)) # 704 points # Now use pdal to clamp points that have intensity 100 <= v < 300 - pipeline = pdal.Filter.range(limits="Intensity[100:300)").pipeline(intensity) + pipeline = pdal.Filter.expression(expression="Intensity >= 100 && Intensity < 300").pipeline(intensity) print(pipeline.execute()) # 387 points clamped = pipeline.arrays[0] @@ -203,7 +218,7 @@ returns an iterator object that yields Numpy arrays of up to ``chunk_size`` size .. code-block:: python import pdal - pipeline = pdal.Reader("test/data/autzen-utm.las") | pdal.Filter.range(limits="Intensity[80:120)") + pipeline = pdal.Reader("test/data/autzen-utm.las") | pdal.Filter.expression(expression="Intensity > 80 && Intensity < 120)") for array in pipeline.iterator(chunk_size=500): print(len(array)) # or to concatenate all arrays into one From b83d78dcc728fc3ccc190df5aefcdf65c7a6a814 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Fri, 4 Oct 2024 15:00:37 -0500 Subject: [PATCH 02/10] update deprecated miniforge-variant to Miniforge --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47180b8d..49934cbd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: - name: Setup micromamba uses: conda-incubator/setup-miniconda@v3 with: - miniforge-variant: Mambaforge + miniforge-variant: Miniforge3 miniforge-version: latest python-version: ${{ matrix.python-version }} use-mamba: true From f8583508cb911cacbdbab065beaf3f41009c7ef4 Mon Sep 17 00:00:00 2001 From: James Ford Date: Sat, 5 Oct 2024 09:04:23 +1300 Subject: [PATCH 03/10] Add GeoDataFrame support to Pipeline (#173) * Added GeoDataFrame support to pipeline.py Added basic GeoPandas GeoDataFrame support. If GeoPandas is installed users can read an array from an executed pipeline and return a GeoDataFrame, with optional arguments for XY vs XYZ point and CRS. DataFrames passed to the Pipeline constructor will drop the "geometry" column if present. * Update test_pipeline.py Added test for GeoDataFrames * add geopandas to environment reqs --------- Co-authored-by: Howard Butler --- .github/environment.yml | 2 +- src/pdal/pipeline.py | 22 +++++++++++++-- test/test_pipeline.py | 59 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/.github/environment.yml b/.github/environment.yml index 5a4905c7..76875d6b 100644 --- a/.github/environment.yml +++ b/.github/environment.yml @@ -9,4 +9,4 @@ dependencies: - pdal - pytest - meshio - - pandas + - geopandas diff --git a/src/pdal/pipeline.py b/src/pdal/pipeline.py index c13a6d2c..37d98163 100644 --- a/src/pdal/pipeline.py +++ b/src/pdal/pipeline.py @@ -17,6 +17,11 @@ except ModuleNotFoundError: # pragma: no cover DataFrame = None +try: + from geopandas import GeoDataFrame, points_from_xy +except ModuleNotFoundError: # pragma: no cover + GeoDataFrame = points_from_xy = None + from . import drivers, libpdalpython LogLevelToPDAL = { @@ -45,7 +50,7 @@ def __init__( # Convert our data frames to Numpy Structured Arrays if dataframes: - arrays = [df.to_records() for df in dataframes] + arrays = [df.to_records() if not "geometry" in df.columns else df.drop(columns=["geometry"]).to_records() for df in dataframes] super().__init__() self._stages: List[Stage] = [] @@ -124,13 +129,26 @@ def get_meshio(self, idx: int) -> Optional[Mesh]: [("triangle", np.stack((mesh["A"], mesh["B"], mesh["C"]), 1))], ) - def get_dataframe(self, idx: int) -> Optional[DataFrame]: if DataFrame is None: raise RuntimeError("Pandas support requires Pandas to be installed") return DataFrame(self.arrays[idx]) + def get_geodataframe(self, idx: int, xyz: bool=False, crs: Any=None) -> Optional[GeoDataFrame]: + if GeoDataFrame is None: + raise RuntimeError("GeoPandas support requires GeoPandas to be installed") + df = DataFrame(self.arrays[idx]) + coords = [df["X"], df["Y"], df["Z"]] if xyz else [df["X"], df["Y"]] + geometry = points_from_xy(*coords) + gdf = GeoDataFrame( + df, + geometry=geometry, + crs=crs, + ) + df = coords = geometry = None + return gdf + def _get_json(self) -> str: return self.toJSON() diff --git a/test/test_pipeline.py b/test/test_pipeline.py index 5a40b58e..c0c417a8 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -541,6 +541,65 @@ def test_load(self): assert data["Intensity"].sum() == 57684 +class TestGeoDataFrame: + + @pytest.mark.skipif( + not pdal.pipeline.GeoDataFrame, + reason="geopandas is not available", + ) + def test_fetch(self): + r = pdal.Reader(os.path.join(DATADIRECTORY,"autzen-utm.las")) + p = r.pipeline() + p.execute() + record_count = p.arrays[0].shape[0] + dimension_count = len(p.arrays[0].dtype) + gdf = p.get_geodataframe(0) + gdf_xyz = p.get_geodataframe(0, xyz=True) + gdf_crs = p.get_geodataframe(0, crs="EPSG:4326") + assert len(gdf) == record_count + assert len(gdf.columns) == dimension_count + 1 + assert isinstance(gdf, pdal.pipeline.GeoDataFrame) + assert gdf.geometry.is_valid.all() + assert not gdf.geometry.is_empty.any() + assert gdf.crs is None + assert gdf.geometry.z.isna().all() + assert not gdf_xyz.geometry.z.isna().any() + assert gdf_crs.crs.srs == "EPSG:4326" + + @pytest.mark.skipif( + not pdal.pipeline.GeoDataFrame, + reason="geopandas is not available", + ) + def test_load(self): + r = pdal.Reader(os.path.join(DATADIRECTORY,"autzen-utm.las")) + p = r.pipeline() + p.execute() + data = p.arrays[0] + gdf = pdal.pipeline.GeoDataFrame( + data, + geometry=pdal.pipeline.points_from_xy(data["X"], data["Y"], data["Z"]) + ) + dataframes = [gdf, gdf, gdf] + filter_intensity = """{ + "pipeline":[ + { + "type":"filters.range", + "limits":"Intensity[100:300)" + } + ] + }""" + p = pdal.Pipeline(filter_intensity, dataframes = dataframes) + p.execute() + arrays = p.arrays + assert len(arrays) == 3 + + # We copied the array three times. Sum the Intensity values + # post filtering to see if we had our intended effect + for data in arrays: + assert len(data) == 387 + assert data["Intensity"].sum() == 57684 + + class TestPipelineIterator: @pytest.mark.parametrize("filename", ["sort.json", "sort.py"]) def test_non_streamable(self, filename): From fdabd27683eece4747ada3d2c951500a5dd3d6a1 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Tue, 29 Oct 2024 11:15:12 -0500 Subject: [PATCH 04/10] add Python 3.13 to build matrix and drop 3.9 (#181) * add Python 3.13 to build matrix and drop 3.9 * adjust matrix * adjust matrix 2 --- .github/workflows/build.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49934cbd..b460c7b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,11 +24,13 @@ jobs: fail-fast: true matrix: os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] - python-version: ['3.9', '3.10', '3.11', '3.12'] - numpy-version: ['1.24', '2.0'] + python-version: ['3.10', '3.11', '3.12', '3.13'] + numpy-version: ['1.24', '2.1'] exclude: - python-version: '3.12' numpy-version: '1.24' + - python-version: '3.13' + numpy-version: '1.24' steps: - name: Check out python-pdal From f2fdc7c48550f7f82972da2cc9e7d24fb91bb551 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Fri, 15 Nov 2024 13:07:42 -0600 Subject: [PATCH 05/10] align minimum CMake version to 3.16 to align with GDAL and PDAL (#183) --- CMakeLists.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e22cd1a1..61610cfb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.11.0) +cmake_minimum_required(VERSION 3.16.0) project(pdal-python VERSION ${SKBUILD_PROJECT_VERSION} DESCRIPTION "PDAL Python bindings" HOMEPAGE_URL "https://github.com/PDAL/Python") @@ -6,7 +6,6 @@ project(pdal-python VERSION ${SKBUILD_PROJECT_VERSION} set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -set(CMAKE_BUILD_TYPE "Release") # Python-finding settings set(Python3_FIND_STRATEGY "LOCATION") @@ -25,7 +24,7 @@ endif() find_package(Python3 COMPONENTS Interpreter ${DEVELOPMENT_COMPONENT} NumPy REQUIRED) # find PDAL. Require 2.1+ -find_package(PDAL 2.6 REQUIRED) +find_package(PDAL 2.7 REQUIRED) # find PyBind11 find_package(pybind11 REQUIRED) From 2d035abdae14538a4314cfe519c6c05f078ae246 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Tue, 19 Nov 2024 15:34:07 -0600 Subject: [PATCH 06/10] Support limiting dimensions with execute() and executeStreaming() (#184) --- src/pdal/PyArray.cpp | 5 +++-- src/pdal/PyArray.hpp | 3 ++- src/pdal/PyPipeline.cpp | 15 +++++++++++++-- src/pdal/PyPipeline.hpp | 4 ++-- src/pdal/StreamableExecutor.cpp | 8 +++++++- src/pdal/StreamableExecutor.hpp | 3 ++- src/pdal/libpdalpython.cpp | 25 ++++++++++++------------- test/test_pipeline.py | 23 +++++++++++++++++++++++ 8 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/pdal/PyArray.cpp b/src/pdal/PyArray.cpp index 55f87b5d..0dc875d9 100644 --- a/src/pdal/PyArray.cpp +++ b/src/pdal/PyArray.cpp @@ -78,7 +78,7 @@ Dimension::Type pdalType(int t) return Type::None; } -std::string toString(PyObject *pname) +std::string pyObjectToString(PyObject *pname) { PyObject* r = PyObject_Str(pname); if (!r) @@ -92,6 +92,7 @@ std::string toString(PyObject *pname) #if NPY_ABI_VERSION < 0x02000000 #define PyDataType_FIELDS(descr) ((descr)->fields) + #define PyDataType_NAMES(descr) ((descr)->names) #endif Array::Array(PyArrayObject* array) : m_array(array), m_rowMajor(true) @@ -124,7 +125,7 @@ Array::Array(PyArrayObject* array) : m_array(array), m_rowMajor(true) for (int i = 0; i < numFields; ++i) { - std::string name = toString(PyList_GetItem(names, i)); + std::string name = python::pyObjectToString(PyList_GetItem(names, i)); if (name == "X") xyz |= 1; else if (name == "Y") diff --git a/src/pdal/PyArray.hpp b/src/pdal/PyArray.hpp index e2da961f..d2fb9674 100644 --- a/src/pdal/PyArray.hpp +++ b/src/pdal/PyArray.hpp @@ -55,6 +55,7 @@ namespace pdal namespace python { + class ArrayIter; @@ -87,7 +88,7 @@ class PDAL_DLL Array }; -class ArrayIter +class PDAL_DLL ArrayIter { public: ArrayIter(const ArrayIter&) = delete; diff --git a/src/pdal/PyPipeline.cpp b/src/pdal/PyPipeline.cpp index 85ea028a..b64ef3bb 100644 --- a/src/pdal/PyPipeline.cpp +++ b/src/pdal/PyPipeline.cpp @@ -73,8 +73,13 @@ PipelineExecutor::PipelineExecutor( } -point_count_t PipelineExecutor::execute() +point_count_t PipelineExecutor::execute(pdal::StringList allowedDims) { + if (allowedDims.size()) + { + m_manager.pointTable().layout()->setAllowedDims(allowedDims); + } + point_count_t count = m_manager.execute(); m_executed = true; return count; @@ -92,9 +97,14 @@ std::string PipelineExecutor::getSrsWKT2() const return output; } -point_count_t PipelineExecutor::executeStream(point_count_t streamLimit) +point_count_t PipelineExecutor::executeStream(point_count_t streamLimit, + pdal::StringList allowedDims) { CountPointTable table(streamLimit); + if (allowedDims.size()) + { + pointTable().layout()->setAllowedDims(allowedDims); + } m_manager.executeStream(table); m_executed = true; return table.count(); @@ -272,6 +282,7 @@ PyObject* buildNumpyDescriptor(PointLayoutPtr layout) { return layout->dimOffset(id1) < layout->dimOffset(id2); }; + auto dims = layout->dims(); std::sort(dims.begin(), dims.end(), sortByOffset); diff --git a/src/pdal/PyPipeline.hpp b/src/pdal/PyPipeline.hpp index c32abcfe..5233763f 100644 --- a/src/pdal/PyPipeline.hpp +++ b/src/pdal/PyPipeline.hpp @@ -60,8 +60,8 @@ class PDAL_DLL PipelineExecutor { PipelineExecutor(std::string const& json, std::vector> arrays, int level); virtual ~PipelineExecutor() = default; - point_count_t execute(); - point_count_t executeStream(point_count_t streamLimit); + point_count_t execute(pdal::StringList allowedDims); + point_count_t executeStream(point_count_t streamLimit, pdal::StringList allowedDims); const PointViewSet& views() const; std::string getPipeline() const; diff --git a/src/pdal/StreamableExecutor.cpp b/src/pdal/StreamableExecutor.cpp index 9f5b4b8b..5fa01931 100644 --- a/src/pdal/StreamableExecutor.cpp +++ b/src/pdal/StreamableExecutor.cpp @@ -187,11 +187,17 @@ StreamableExecutor::StreamableExecutor(std::string const& json, std::vector> arrays, int level, point_count_t chunkSize, - int prefetch) + int prefetch, + pdal::StringList allowedDims) : PipelineExecutor(json, arrays, level) , m_table(chunkSize, prefetch) , m_exc(nullptr) { + + if (allowedDims.size()) + { + m_table.layout()->setAllowedDims(allowedDims); + } m_thread.reset(new std::thread([this]() { try { diff --git a/src/pdal/StreamableExecutor.hpp b/src/pdal/StreamableExecutor.hpp index 45d015c3..f565c8ee 100644 --- a/src/pdal/StreamableExecutor.hpp +++ b/src/pdal/StreamableExecutor.hpp @@ -81,7 +81,8 @@ class StreamableExecutor : public PipelineExecutor std::vector> arrays, int level, point_count_t chunkSize, - int prefetch); + int prefetch, + pdal::StringList allowedDim); ~StreamableExecutor(); MetadataNode getMetadata() { return m_table.metadata(); } diff --git a/src/pdal/libpdalpython.cpp b/src/pdal/libpdalpython.cpp index 229f928d..e5fc353a 100644 --- a/src/pdal/libpdalpython.cpp +++ b/src/pdal/libpdalpython.cpp @@ -165,28 +165,27 @@ namespace pdal { class Pipeline { public: - point_count_t execute() { + point_count_t execute(pdal::StringList allowedDims) { point_count_t response(0); { py::gil_scoped_release release; - response = getExecutor()->execute(); + response = getExecutor()->execute(allowedDims); } return response; - } - point_count_t executeStream(point_count_t streamLimit) { + point_count_t executeStream(point_count_t streamLimit, pdal::StringList allowedDims) { point_count_t response(0); { py::gil_scoped_release release; - response = getExecutor()->executeStream(streamLimit); + response = getExecutor()->executeStream(streamLimit, allowedDims); } return response; } - std::unique_ptr iterator(int chunk_size, int prefetch) { + std::unique_ptr iterator(int chunk_size, int prefetch, pdal::StringList allowedDims) { return std::unique_ptr(new PipelineIterator( - getJson(), _inputs, _loglevel, chunk_size, prefetch + getJson(), _inputs, _loglevel, chunk_size, prefetch, allowedDims )); } @@ -308,9 +307,9 @@ namespace pdal { py::class_(m, "Pipeline") .def(py::init<>()) - .def("execute", &Pipeline::execute) - .def("execute_streaming", &Pipeline::executeStream, "chunk_size"_a=10000) - .def("iterator", &Pipeline::iterator, "chunk_size"_a=10000, "prefetch"_a=0) + .def("execute", &Pipeline::execute, py::arg("allowed_dims") =py::list()) + .def("execute_streaming", &Pipeline::executeStream, "chunk_size"_a=10000, py::arg("allowed_dims") =py::list()) + .def("iterator", &Pipeline::iterator, "chunk_size"_a=10000, "prefetch"_a=0, py::arg("allowed_dims") =py::list()) .def_property("inputs", nullptr, &Pipeline::setInputs) .def_property("loglevel", &Pipeline::getLoglevel, &Pipeline::setLogLevel) .def_property_readonly("log", &Pipeline::getLog) @@ -333,10 +332,10 @@ namespace pdal { m.def("infer_writer_driver", &getWriterDriver); if (pdal::Config::versionMajor() < 2) - throw pybind11::import_error("PDAL version must be >= 2.6"); + throw pybind11::import_error("PDAL version must be >= 2.7"); - if (pdal::Config::versionMajor() == 2 && pdal::Config::versionMinor() < 6) - throw pybind11::import_error("PDAL version must be >= 2.6"); + if (pdal::Config::versionMajor() == 2 && pdal::Config::versionMinor() < 7) + throw pybind11::import_error("PDAL version must be >= 2.7"); }; }; // namespace pdal diff --git a/test/test_pipeline.py b/test/test_pipeline.py index c0c417a8..fcec914a 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -82,6 +82,17 @@ def test_execute_streaming(self, filename): count2 = r.execute_streaming(chunk_size=100) assert count == count2 + + @pytest.mark.parametrize("filename", ["range.json", "range.py"]) + def test_subsetstreaming(self, filename): + """Can we fetch a subset of PDAL dimensions as a numpy array while streaming""" + r = get_pipeline(filename) + limit = ['X','Y','Z','Intensity'] + arrays = list(r.iterator(chunk_size=100,allowed_dims=limit)) + assert len(arrays) == 11 + assert len(arrays[0].dtype) == 4 + + @pytest.mark.parametrize("filename", ["sort.json", "sort.py"]) def test_execute_streaming_non_streamable(self, filename): r = get_pipeline(filename) @@ -113,6 +124,18 @@ def test_array(self, filename): assert a[0][0] == 635619.85 assert a[1064][2] == 456.92 + @pytest.mark.parametrize("filename", ["sort.json", "sort.py"]) + def test_subsetarray(self, filename): + """Can we fetch a subset of PDAL dimensions as a numpy array""" + r = get_pipeline(filename) + limit = ['X','Y','Z'] + r.execute(allowed_dims=limit) + arrays = r.arrays + assert len(arrays) == 1 + assert len(arrays[0].dtype) == 3 + + + @pytest.mark.parametrize("filename", ["sort.json", "sort.py"]) def test_metadata(self, filename): """Can we fetch PDAL metadata""" From 32328ac7050bf517b6961cdc297548a384766b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o?= <8913464+joaori@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:51:10 +0000 Subject: [PATCH 07/10] Adding support for streaming through the input arrays of a pipeline (#182) * Adding support for streaming the input arrays of a pipeline * Code cleanup based on code review. * Updated documentation to include mention to reading with stream handler. --- README.rst | 98 ++++++++++++++++++++++++ src/pdal/PyArray.cpp | 82 +++++++++++++++----- src/pdal/PyArray.hpp | 19 +++-- src/pdal/PyPipeline.cpp | 14 +++- src/pdal/libpdalpython.cpp | 20 ++++- src/pdal/pipeline.py | 12 ++- test/test_pipeline.py | 148 +++++++++++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 37 deletions(-) diff --git a/README.rst b/README.rst index 9b6e123f..cbab9ac1 100644 --- a/README.rst +++ b/README.rst @@ -208,6 +208,104 @@ PDAL and Python: with tiledb.open("clamped") as a: print(a.schema) +Reading using Numpy Arrays as buffers (advanced) +................................................................................ + +It's also possible to treat the Numpy arrays passed to PDAL as buffers that are iteratively populated through +custom python functions during the execution of the pipeline. + +This may be useful in cases where you want the reading of the input data to be handled in a streamable fashion, +like for example: + +* When the total Numpy array data wouldn't fit into memory. +* To initiate execution of a streamable PDAL pipeline while the input data is still being read. + +To enable this mode, you just need to include the python populate function along with each corresponding Numpy array. + +.. code-block:: python + + # Numpy array to be used as buffer + in_buffer = np.zeros(max_chunk_size, dtype=[("X", float), ("Y", float), ("Z", float)]) + + # The function to populate the buffer iteratively + def load_next_chunk() -> int: + """ + Function called by PDAL before reading the data from the buffer. + + IMPORTANT: must return the total number of items to be read from the buffer. + The Pipeline execution will keep calling this function in a loop until 0 is returned. + """ + # + # Replace here with your code that populates the buffer and returns the number of elements to read + # + chunk_size = next_chunk.size + in_buffer[:chunk_size]["X"] = next_chunk[:]["X"] + in_buffer[:chunk_size]["Y"] = next_chunk[:]["Y"] + in_buffer[:chunk_size]["Z"] = next_chunk[:]["Z"] + + return chunk_size + + # Configure input array and handler during Pipeline initialization... + p = pdal.Pipeline(pipeline_json, arrays=[in_buffer], stream_handlers=[load_next_chunk]) + + # ...alternatively you can use the setter on an existing Pipeline + # p.inputs = [(in_buffer, load_next_chunk)] + +The following snippet provides a simple example of how to use a Numpy array as buffer to support writing through PDAL +with total control over the maximum amount of memory to use. + +.. raw:: html + +
+ Example: Streaming the read and write of a very large LAZ file with low memory footprint + +.. code-block:: python + + import numpy as np + import pdal + + in_chunk_size = 10_000_000 + in_pipeline = pdal.Reader.las(**{ + "filename": "in_test.laz" + }).pipeline() + + in_pipeline_it = in_pipeline.iterator(in_chunk_size).__iter__() + + out_chunk_size = 50_000_000 + out_file = "out_test.laz" + out_pipeline = pdal.Writer.las( + filename=out_file + ).pipeline() + + out_buffer = np.zeros(in_chunk_size, dtype=[("X", float), ("Y", float), ("Z", float)]) + + def load_next_chunk(): + try: + next_chunk = next(in_pipeline_it) + except StopIteration: + # Stops the streaming + return 0 + + chunk_size = next_chunk.size + out_buffer[:chunk_size]["X"] = next_chunk[:]["X"] + out_buffer[:chunk_size]["Y"] = next_chunk[:]["Y"] + out_buffer[:chunk_size]["Z"] = next_chunk[:]["Z"] + + print(f"Loaded next chunk -> {chunk_size}") + + return chunk_size + + out_pipeline.inputs = [(out_buffer, load_next_chunk)] + + out_pipeline.loglevel = 20 # INFO + count = out_pipeline.execute_streaming(out_chunk_size) + + print(f"\nWROTE - {count}") + +.. raw:: html + +
+ Executing Streamable Pipelines ................................................................................ Streamable pipelines (pipelines that consist exclusively of streamable PDAL diff --git a/src/pdal/PyArray.cpp b/src/pdal/PyArray.cpp index 0dc875d9..62b4875a 100644 --- a/src/pdal/PyArray.cpp +++ b/src/pdal/PyArray.cpp @@ -34,7 +34,6 @@ #include "PyArray.hpp" #include -#include namespace pdal { @@ -95,7 +94,8 @@ std::string pyObjectToString(PyObject *pname) #define PyDataType_NAMES(descr) ((descr)->names) #endif -Array::Array(PyArrayObject* array) : m_array(array), m_rowMajor(true) +Array::Array(PyArrayObject* array, std::shared_ptr stream_handler) + : m_array(array), m_rowMajor(true), m_stream_handler(std::move(stream_handler)) { Py_XINCREF(array); @@ -164,40 +164,77 @@ Array::~Array() Py_XDECREF(m_array); } - -ArrayIter& Array::iterator() +std::shared_ptr Array::iterator() { - ArrayIter *it = new ArrayIter(m_array); - m_iterators.emplace_back((it)); - return *it; + return std::make_shared(m_array, m_stream_handler); } - -ArrayIter::ArrayIter(PyArrayObject* np_array) +ArrayIter::ArrayIter(PyArrayObject* np_array, std::shared_ptr stream_handler) + : m_stream_handler(std::move(stream_handler)) { + // Create iterator m_iter = NpyIter_New(np_array, - NPY_ITER_EXTERNAL_LOOP | NPY_ITER_READONLY | NPY_ITER_REFS_OK, - NPY_KEEPORDER, NPY_NO_CASTING, NULL); + NPY_ITER_EXTERNAL_LOOP | NPY_ITER_READONLY | NPY_ITER_REFS_OK, + NPY_KEEPORDER, NPY_NO_CASTING, NULL); if (!m_iter) throw pdal_error("Unable to create numpy iterator."); + initIterator(); +} + +void ArrayIter::initIterator() +{ + // For a stream handler, first execute it to get the buffer populated and know the size of the data to iterate + int64_t stream_chunk_size = 0; + if (m_stream_handler) { + stream_chunk_size = (*m_stream_handler)(); + if (!stream_chunk_size) { + m_done = true; + return; + } + } + + // Initialize the iterator function char *itererr; m_iterNext = NpyIter_GetIterNext(m_iter, &itererr); if (!m_iterNext) { NpyIter_Deallocate(m_iter); - throw pdal_error(std::string("Unable to create numpy iterator: ") + - itererr); + m_iter = nullptr; + throw pdal_error(std::string("Unable to retrieve iteration function from numpy iterator: ") + itererr); } m_data = NpyIter_GetDataPtrArray(m_iter); - m_stride = NpyIter_GetInnerStrideArray(m_iter); - m_size = NpyIter_GetInnerLoopSizePtr(m_iter); + m_stride = *NpyIter_GetInnerStrideArray(m_iter); + m_size = *NpyIter_GetInnerLoopSizePtr(m_iter); + if (stream_chunk_size) { + // Ensure chunk size is valid and then limit iteration accordingly + if (0 < stream_chunk_size && stream_chunk_size <= m_size) { + m_size = stream_chunk_size; + } else { + throw pdal_error(std::string("Stream chunk size not in the range of array length: ") + + std::to_string(stream_chunk_size)); + } + } m_done = false; } +void ArrayIter::resetIterator() +{ + // Reset the iterator to the initial state + if (NpyIter_Reset(m_iter, NULL) != NPY_SUCCEED) { + NpyIter_Deallocate(m_iter); + m_iter = nullptr; + throw pdal_error("Unable to reset numpy iterator."); + } + + initIterator(); +} + ArrayIter::~ArrayIter() { - NpyIter_Deallocate(m_iter); + if (m_iter != nullptr) { + NpyIter_Deallocate(m_iter); + } } ArrayIter& ArrayIter::operator++() @@ -205,10 +242,15 @@ ArrayIter& ArrayIter::operator++() if (m_done) return *this; - if (--(*m_size)) - *m_data += *m_stride; - else if (!m_iterNext(m_iter)) - m_done = true; + if (--m_size) { + *m_data += m_stride; + } else if (!m_iterNext(m_iter)) { + if (m_stream_handler) { + resetIterator(); + } else { + m_done = true; + } + } return *this; } diff --git a/src/pdal/PyArray.hpp b/src/pdal/PyArray.hpp index d2fb9674..a702176f 100644 --- a/src/pdal/PyArray.hpp +++ b/src/pdal/PyArray.hpp @@ -58,6 +58,7 @@ namespace python class ArrayIter; +using ArrayStreamHandler = std::function; class PDAL_DLL Array { @@ -65,7 +66,7 @@ class PDAL_DLL Array using Shape = std::array; using Fields = std::vector; - Array(PyArrayObject* array); + Array(PyArrayObject* array, std::shared_ptr stream_handler = {}); ~Array(); Array(Array&& a) = default; @@ -77,14 +78,14 @@ class PDAL_DLL Array bool rowMajor() const { return m_rowMajor; }; Shape shape() const { return m_shape; } const Fields& fields() const { return m_fields; }; - ArrayIter& iterator(); + std::shared_ptr iterator(); private: PyArrayObject* m_array; Fields m_fields; bool m_rowMajor; Shape m_shape {}; - std::vector> m_iterators; + std::shared_ptr m_stream_handler; }; @@ -94,7 +95,7 @@ class PDAL_DLL ArrayIter ArrayIter(const ArrayIter&) = delete; ArrayIter() = delete; - ArrayIter(PyArrayObject*); + ArrayIter(PyArrayObject*, std::shared_ptr); ~ArrayIter(); ArrayIter& operator++(); @@ -102,12 +103,16 @@ class PDAL_DLL ArrayIter char* operator*() const { return *m_data; } private: - NpyIter *m_iter; + NpyIter *m_iter = nullptr; NpyIter_IterNextFunc *m_iterNext; char **m_data; - npy_intp *m_size; - npy_intp *m_stride; + npy_intp m_size; + npy_intp m_stride; bool m_done; + + std::shared_ptr m_stream_handler; + void initIterator(); + void resetIterator(); }; } // namespace python diff --git a/src/pdal/PyPipeline.cpp b/src/pdal/PyPipeline.cpp index b64ef3bb..ccac0692 100644 --- a/src/pdal/PyPipeline.cpp +++ b/src/pdal/PyPipeline.cpp @@ -245,14 +245,20 @@ void PipelineExecutor::addArrayReaders(std::vector> array for (auto f : array->fields()) r.pushField(f); - ArrayIter& iter = array->iterator(); - auto incrementer = [&iter](PointId id) -> char * + auto arrayIter = array->iterator(); + auto incrementer = [arrayIter, firstPoint = true](PointId id) mutable -> char * { - if (! iter) + ArrayIter& iter = *arrayIter; + if (!firstPoint && iter) { + ++iter; + } else { + firstPoint = false; + } + + if (!iter) return nullptr; char *c = *iter; - ++iter; return c; }; diff --git a/src/pdal/libpdalpython.cpp b/src/pdal/libpdalpython.cpp index e5fc353a..09118fbf 100644 --- a/src/pdal/libpdalpython.cpp +++ b/src/pdal/libpdalpython.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -189,11 +190,22 @@ namespace pdal { )); } - void setInputs(std::vector ndarrays) { + void setInputs(const std::vector& inputs) { _inputs.clear(); - for (const auto& ndarray: ndarrays) { - PyArrayObject* ndarray_ptr = (PyArrayObject*)ndarray.ptr(); - _inputs.push_back(std::make_shared(ndarray_ptr)); + for (const auto& input_obj: inputs) { + if (py::isinstance(input_obj)) { + // Backward compatibility for accepting list of numpy arrays + auto ndarray = input_obj.cast(); + _inputs.push_back(std::make_shared((PyArrayObject*)ndarray.ptr())); + } else { + // Now expected to be a list of pairs: (numpy array, stream handler) + auto input = input_obj.cast>(); + _inputs.push_back(std::make_shared( + (PyArrayObject*)input.first.ptr(), + input.second ? + std::make_shared(input.second) + : nullptr)); + } } delExecutor(); } diff --git a/src/pdal/pipeline.py b/src/pdal/pipeline.py index 37d98163..60a181c0 100644 --- a/src/pdal/pipeline.py +++ b/src/pdal/pipeline.py @@ -2,7 +2,7 @@ import json import logging -from typing import Any, Container, Dict, Iterator, List, Optional, Sequence, Union, cast +from typing import Any, Container, Dict, Iterator, List, Optional, Sequence, Union, cast, Callable import numpy as np import pathlib @@ -41,6 +41,7 @@ def __init__( loglevel: int = logging.ERROR, json: Optional[str] = None, dataframes: Sequence[DataFrame] = (), + stream_handlers: Sequence[Callable[[], int]] = (), ): if json: @@ -58,7 +59,14 @@ def __init__( stages = _parse_stages(spec) if isinstance(spec, str) else spec for stage in stages: self |= stage - self.inputs = arrays + + if stream_handlers: + if len(stream_handlers) != len(arrays): + raise RuntimeError("stream_handlers must match the number of specified input arrays / dataframes") + self.inputs = [(a, h) for a, h in zip(arrays, stream_handlers)] + else: + self.inputs = [(a, None) for a in arrays] + self.loglevel = loglevel def __getstate__(self): diff --git a/test/test_pipeline.py b/test/test_pipeline.py index fcec914a..35795ea3 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -3,6 +3,8 @@ import os import sys +from typing import List +from itertools import product import numpy as np import pytest @@ -728,3 +730,149 @@ def test_multiple_iterators(self, filename): np.testing.assert_array_equal(a1, a2) assert next(it1, None) is None assert next(it2, None) is None + + +def gen_chunk(count, random_seed = 12345): + rng = np.random.RandomState(count*random_seed) + # Generate dummy data + result = np.zeros(count, dtype=[("X", float), ("Y", float), ("Z", float)]) + result['X'][:] = rng.uniform(-2, -1, count) + result['Y'][:] = rng.uniform(1, 2, count) + result['Z'][:] = rng.uniform(3, 4, count) + return result + + +class TestPipelineInputStreams(): + + # Test cases + ONE_ARRAY_FULL = [[gen_chunk(1234)]] + MULTI_ARRAYS_FULL = [*ONE_ARRAY_FULL, [gen_chunk(4321)]] + + ONE_ARRAY_STREAMED = [[gen_chunk(10), gen_chunk(7), gen_chunk(3), gen_chunk(5), gen_chunk(1)]] + MULTI_ARRAYS_STREAMED = [*ONE_ARRAY_STREAMED, [gen_chunk(5), gen_chunk(2), gen_chunk(3), gen_chunk(1)]] + + MULTI_ARRAYS_MIXED = [ + *MULTI_ARRAYS_STREAMED, + *MULTI_ARRAYS_FULL + ] + + @pytest.mark.parametrize("in_arrays_chunks, use_setter", [ + (arrays_chunks, use_setter) for arrays_chunks, use_setter in product([ + ONE_ARRAY_FULL, MULTI_ARRAYS_FULL, + ONE_ARRAY_STREAMED, MULTI_ARRAYS_STREAMED, + MULTI_ARRAYS_MIXED + ], ['False', 'True']) + ]) + def test_pipeline_run(self, in_arrays_chunks, use_setter): + """ + Test case to validate possible usages: + - Combining "full" arrays and "streamed" ones + - Setting input arrays through the Pipeline constructor or the setter + """ + # Assuming stream mode for lists that contain more than one chunk. + # And that first chunk is the biggest of all, to simplify input buffer size creation. + in_arrays = [ + np.zeros(chunks[0].shape, chunks[0].dtype) if len(chunks) > 1 else chunks[0] + for chunks in in_arrays_chunks + ] + + def get_stream_handler(in_array, in_array_chunks): + in_array_chunks_it = iter(in_array_chunks) + def load_next_chunk(): + try: + next_chunk = next(in_array_chunks_it) + except StopIteration: + return 0 + + chunk_size = next_chunk.size + in_array[:chunk_size]["X"] = next_chunk[:]["X"] + in_array[:chunk_size]["Y"] = next_chunk[:]["Y"] + in_array[:chunk_size]["Z"] = next_chunk[:]["Z"] + + return chunk_size + + return load_next_chunk + + stream_handlers = [ + get_stream_handler(arr, chunks) if len(chunks) > 1 else None + for arr, chunks in zip(in_arrays, in_arrays_chunks) + ] + + expected_count = sum([sum([len(c) for c in chunks]) for chunks in in_arrays_chunks]) + + pipeline = """ + { + "pipeline": [{ + "type": "filters.stats" + }] + } + """ + if use_setter: + p = pdal.Pipeline(pipeline) + p.inputs = [(a, h) for a, h in zip(in_arrays, stream_handlers)] + else: + p = pdal.Pipeline(pipeline, arrays=in_arrays, stream_handlers=stream_handlers) + + count = p.execute() + out_arrays = p.arrays + assert count == expected_count + assert len(out_arrays) == len(in_arrays) + + for in_array_chunks, out_array in zip(in_arrays_chunks, out_arrays): + np.testing.assert_array_equal(out_array, np.concatenate(in_array_chunks)) + + @pytest.mark.parametrize("in_arrays, use_setter", [ + (arrays, use_setter) for arrays, use_setter in product([ + [c[0] for c in ONE_ARRAY_FULL], + [c[0] for c in MULTI_ARRAYS_FULL] + ], ['False', 'True']) + ]) + def test_pipeline_run_backward_compat(self, in_arrays, use_setter: bool): + expected_count = sum([len(a) for a in in_arrays]) + + pipeline = """ + { + "pipeline": [{ + "type": "filters.stats" + }] + } + """ + if use_setter: + p = pdal.Pipeline(pipeline) + p.inputs = in_arrays + else: + p = pdal.Pipeline(pipeline, arrays=in_arrays) + + count = p.execute() + out_arrays = p.arrays + assert count == expected_count + assert len(out_arrays) == len(in_arrays) + + for in_array, out_array in zip(in_arrays, out_arrays): + np.testing.assert_array_equal(out_array, in_array) + + @pytest.mark.parametrize("in_array, invalid_chunk_size", [ + (in_array, invalid_chunk_size) for in_array, invalid_chunk_size in product( + [gen_chunk(1234)], + [-1, 12345]) + ]) + def test_pipeline_fail_with_invalid_chunk_size(self, in_array, invalid_chunk_size): + """ + Ensure execution fails when using an invalid stream handler: + - One that returns a negative chunk size + - One that returns a chunk size bigger than the buffer capacity + """ + was_called = False + def invalid_stream_handler(): + nonlocal was_called + if was_called: + # avoid infinite loop + raise ValueError("Invalid handler should not have been called a second time") + was_called = True + return invalid_chunk_size + + p = pdal.Pipeline(arrays=[in_array], stream_handlers=[invalid_stream_handler]) + with pytest.raises(RuntimeError, + match=f"Stream chunk size not in the range of array length: {invalid_chunk_size}"): + p.execute() + From b569f2774ac0a868d637ac35a143fe8ebcffbfb2 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Sun, 11 May 2025 21:32:05 -0500 Subject: [PATCH 08/10] Quickinfo scales (#189) * added additional metadata to quickinfo * use module call to run pytest --------- Co-authored-by: Norman Barker --- .github/workflows/build.yml | 2 +- src/pdal/PyPipeline.cpp | 6 ++++++ test/data/simple.laz | Bin 0 -> 18217 bytes test/test_pipeline.py | 9 +++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 test/data/simple.laz diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b460c7b9..fc69ec07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,5 +74,5 @@ jobs: export PDAL_DRIVER_PATH=$PDAL_PLUGIN_PATH:$PDAL_DRIVER_PATH python -m pdal pdal --drivers --debug - py.test -v test/ + python -m pytest -v test/ diff --git a/src/pdal/PyPipeline.cpp b/src/pdal/PyPipeline.cpp index ccac0692..7f295273 100644 --- a/src/pdal/PyPipeline.cpp +++ b/src/pdal/PyPipeline.cpp @@ -185,6 +185,12 @@ MetadataNode computePreview(Stage* stage) } if (dims.size()) summary.add("dimensions", dims); + + if (!qi.m_metadata.empty() && qi.m_metadata.valid()) + { + summary.add(qi.m_metadata.clone("metadata")); + } + pdal::Utils::toJSON(summary, strm); return summary; diff --git a/test/data/simple.laz b/test/data/simple.laz new file mode 100644 index 0000000000000000000000000000000000000000..6f774c5b715aa4fa8c60b96b3d3f86f6cc9cfc56 GIT binary patch literal 18217 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 literal 0 HcmV?d00001 diff --git a/test/test_pipeline.py b/test/test_pipeline.py index 35795ea3..317c5b69 100644 --- a/test/test_pipeline.py +++ b/test/test_pipeline.py @@ -396,6 +396,15 @@ def test_quickinfo(self): assert 'readers.las' in info.keys() assert info['readers.las']['num_points'] == 1065 + def test_quickinfo_offsets_scales(self): + r = pdal.Reader(os.path.join(DATADIRECTORY,"simple.laz")) + p = r.pipeline() + info = p.quickinfo + assert 'readers.las' in info.keys() + assert 'offset_x' in info['readers.las']['metadata'].keys() + assert 'scale_x' in info['readers.las']['metadata'].keys() + assert info['readers.las']['num_points'] == 1065 + def test_jsonkwarg(self): pipeline = pdal.Reader(os.path.join(DATADIRECTORY,"autzen-utm.las")).pipeline().toJSON() r = pdal.Pipeline(json=pipeline) From 4f36e7e658cc2fc8b355d22feb7f9c6b51889508 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Mon, 23 Jun 2025 21:02:49 -0400 Subject: [PATCH 09/10] bump to 3.5.0 for release --- src/pdal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pdal/__init__.py b/src/pdal/__init__.py index a312f568..f311ba7e 100644 --- a/src/pdal/__init__.py +++ b/src/pdal/__init__.py @@ -1,5 +1,5 @@ __all__ = ["Pipeline", "Stage", "Reader", "Filter", "Writer", "dimensions", "info"] -__version__ = '3.4.5' +__version__ = '3.5.0' from . import libpdalpython from .drivers import inject_pdal_drivers From 55727d33238bae1cf6f8de62e7f583f5d0f78b99 Mon Sep 17 00:00:00 2001 From: Howard Butler Date: Mon, 23 Jun 2025 21:05:04 -0400 Subject: [PATCH 10/10] use PDAL_EXPORT instead of PDAL_DLL (#192) * use PDAL_EXPORT instead of PDAL_DLL and include pdal/util/pdal_util_export.hpp to get it * consolidate export stuff --- src/pdal/PyArray.hpp | 5 +++-- src/pdal/PyPipeline.hpp | 3 ++- src/pdal/export.hpp | 44 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 src/pdal/export.hpp diff --git a/src/pdal/PyArray.hpp b/src/pdal/PyArray.hpp index a702176f..b2aca844 100644 --- a/src/pdal/PyArray.hpp +++ b/src/pdal/PyArray.hpp @@ -34,6 +34,7 @@ #pragma once +#include "export.hpp" #include #define NPY_TARGET_VERSION NPY_1_22_API_VERSION @@ -60,7 +61,7 @@ class ArrayIter; using ArrayStreamHandler = std::function; -class PDAL_DLL Array +class PDAL_EXPORT Array { public: using Shape = std::array; @@ -89,7 +90,7 @@ class PDAL_DLL Array }; -class PDAL_DLL ArrayIter +class PDAL_EXPORT ArrayIter { public: ArrayIter(const ArrayIter&) = delete; diff --git a/src/pdal/PyPipeline.hpp b/src/pdal/PyPipeline.hpp index 5233763f..1eed023f 100644 --- a/src/pdal/PyPipeline.hpp +++ b/src/pdal/PyPipeline.hpp @@ -34,6 +34,7 @@ #pragma once +#include "export.hpp" #include #define NPY_TARGET_VERSION NPY_1_22_API_VERSION @@ -55,7 +56,7 @@ PyArrayObject* meshToNumpyArray(const TriangularMesh* mesh); class Array; -class PDAL_DLL PipelineExecutor { +class PDAL_EXPORT PipelineExecutor { public: PipelineExecutor(std::string const& json, std::vector> arrays, int level); virtual ~PipelineExecutor() = default; diff --git a/src/pdal/export.hpp b/src/pdal/export.hpp new file mode 100644 index 00000000..5a6c9aea --- /dev/null +++ b/src/pdal/export.hpp @@ -0,0 +1,44 @@ +/****************************************************************************** +* Copyright (c) 2025, Hobu Inc. (info@hobu.co) +* +* All rights reserved. +* +* Redistribution and use in source and binary forms, with or without +* modification, are permitted provided that the following +* conditions are met: +* +* * Redistributions of source code must retain the above copyright +* notice, this list of conditions and the following disclaimer. +* * Redistributions in binary form must reproduce the above copyright +* notice, this list of conditions and the following disclaimer in +* the documentation and/or other materials provided +* with the distribution. +* * Neither the name of Hobu, Inc. nor the +* names of its contributors may be used to endorse or promote +* products derived from this software without specific prior +* written permission. +* +* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +* FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +* COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +* OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +* AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +* OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +* OF SUCH DAMAGE. +****************************************************************************/ + + +#include + +#ifndef PDAL_EXPORT +# define PDAL_EXPORT PDAL_DLL +#endif + +#ifndef PDAL_DLL +# define PDAL_DLL PDAL_EXPORT +#endif