From 070fc1d6c23fe2f0580c58c10724a37203d0cf66 Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:27:57 -0800 Subject: [PATCH 01/13] updates to doc --- docs/source/conf.py | 3 + docs/source/metrix.rst | 2 +- pyproject.toml | 5 +- src/pyForMetrix/metricCalculators/types.py | 2 +- src/pyForMetrix/metrix.py | 112 ++++++++++++++------- 5 files changed, 86 insertions(+), 38 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index b6aca00..79394d6 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -37,6 +37,9 @@ 'python': ('https://docs.python.org/3/', None), 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), 'numpy': ('http://docs.scipy.org/doc/numpy/', None), + 'xarray': ('https://docs.xarray.dev/en/stable/', None), + 'pandas': ('https://pandasguide.readthedocs.io/en/latest/', None), + 'geopandas': ('https://geopandas.org/en/stable/', None), } diff --git a/docs/source/metrix.rst b/docs/source/metrix.rst index db08241..8a816d7 100644 --- a/docs/source/metrix.rst +++ b/docs/source/metrix.rst @@ -4,4 +4,4 @@ Calculation base classes .. automodule:: pyForMetrix.metrix :members: :inherited-members: - :special-members: __call__ \ No newline at end of file + :special-members: __call__, __init__ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 58a48da..62a04ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyForMetrix" -version = "0.0.4" +version = "0.0.5" authors = [ { name="Lukas Winiwarter", email="lukas.pypi@winiwarter.dev" }, ] @@ -22,7 +22,8 @@ dependencies = [ "xarray", "matplotlib", "shapely", - "lmoments3" + "lmoments3", + "deprecated" ] #dependencies = [ # "pip>=19.3", diff --git a/src/pyForMetrix/metricCalculators/types.py b/src/pyForMetrix/metricCalculators/types.py index f0fca19..54236b3 100644 --- a/src/pyForMetrix/metricCalculators/types.py +++ b/src/pyForMetrix/metricCalculators/types.py @@ -140,7 +140,7 @@ def __call__(self, points_in_poly: dict, rumple_pixel_size=1): outArray[0] = np.count_nonzero(points[:, 2] > 2.0) / points.shape[0] outArray[1] = np.count_nonzero(points[:, 2] > np.mean(points[:, 2])) / points.shape[0] - rumple = rumple_index(points, rumple_pixel_size) + rumple = rumple_index(points_in_poly, rumple_pixel_size) outArray[2] = rumple return outArray diff --git a/src/pyForMetrix/metrix.py b/src/pyForMetrix/metrix.py index 4395f8f..8e961ff 100644 --- a/src/pyForMetrix/metrix.py +++ b/src/pyForMetrix/metrix.py @@ -7,6 +7,8 @@ import multiprocessing import multiprocessing.shared_memory +from deprecated import deprecated + import numpy as np import pandas as pd import scipy @@ -23,7 +25,6 @@ from pyForMetrix.metricCalculators import MetricCalculator from pyForMetrix.utils.rasterizer import Rasterizer - def parallel_raster_metrics_for_chunk(XVoxelCenter, XVoxelContains, inPoints, outArrayName, outArrayShape, outArrayType, raster_size, raster_min, @@ -45,8 +46,7 @@ def parallel_raster_metrics_for_chunk(XVoxelCenter, XVoxelContains, inPoints, def parallel_custom_raster_metrics_for_chunk(XVoxelCenter, XVoxelContains, inPoints, outArrayName, outArrayShape, outArrayType, raster_size, raster_min, - perc, p_zabovex, - progressbar, metric): + progressbar, metric, metric_options): if progressbar is not None: progressbar.put((0, 1)) shm = multiprocessing.shared_memory.SharedMemory(outArrayName) @@ -57,8 +57,8 @@ def parallel_custom_raster_metrics_for_chunk(XVoxelCenter, XVoxelContains, inPoi points = {key: item[contains, ...] for key, item in inPoints.items()} out_metrics = [] - for mx in metric: - cell_metrics = mx(points, progressbar) + for mx, mo in zip(metric, metric_options): + cell_metrics = mx(points, **mo) out_metrics.append(cell_metrics) outArray[cellX, cellY, :] = np.concatenate(out_metrics) shm.close() @@ -119,6 +119,20 @@ def calc_metrics_parallel(self): class RasterMetrics(Metrics): def __init__(self, points, raster_size, percentiles=np.arange(0, 101, 5), p_zabovex=None, silent=True, pbars=True, raster_min=None, raster_max=None, origin=None): + """ + Class to calculate metrics on a raster (cell) basis. + + Args: + points: :class:`dict` containing keys 'points' and potentially other attributes, which are :numpy:ndarray s containing the points. + raster_size: :class:`float` raster cell size used for calculation + percentiles: deprecated + p_zabovex: deprecated + silent: deprecated + pbars: :class:`bool` whether to show progress bars or not + raster_min: :class:`numpy.ndarray` of shape `(2,)` with the minimum x/y coordinates for the raster (default: derive from point cloud) + raster_max: :class:`numpy.ndarray` of shape `(2,)` with the maximum x/y coordinates for the raster (default: derive from point cloud) + origin: :class:`numpy.ndarray` of shape `(2,)` with the origin x/y coordinates (pixel center) for the raster (default: same as `raster_min`) + """ self.pbars = pbars self.perc = percentiles self.p_zabovex = p_zabovex if p_zabovex is not None else [] @@ -147,6 +161,16 @@ def __init__(self, points, raster_size, percentiles=np.arange(0, 101, 5), p_zabo self.XVoxelContains = XVoxelContains def calc_custom_metrics(self, metrics: MetricCalculator, metric_options=None): + """ + Calculates the given metrics on the point cloud this class was initialized on. + + Args: + metrics: a single :class:`pyForMetrix.metricCalculators.MetricCalculator` instance or a :class:`list` of such classes + metric_options: a :class:`list` of :class:`dict`s with options (kwargs) for each `MetricCalculator`, or None. + + Returns: + An :class:`xarray.Dataset` containing the metric(s) in a raster grid + """ if not isinstance(metrics, list): metrics = [metrics] if metric_options is None: @@ -169,18 +193,34 @@ def calc_custom_metrics(self, metrics: MetricCalculator, metric_options=None): def calc_custom_metrics_parallel(self, metrics, n_chunks=16, n_processes=4, pbar_position=0, - multiprocessing_point_threshold=10_000, *args, **kwargs): + multiprocessing_point_threshold=10_000, metric_options=None): + """ + Calculates the given metrics on the point cloud this class was initialized on, in parallel. + Parallelization is achieved by spawning multiple processes for subsets of the raster cells. Note that + it might be faster to parallelize over input datasets, if they are chunked. + + Args: + metrics: see :func:`calc_custom_metrics` + n_chunks: number of chunks to split the valid raster cells into (more chunks decrease memory usage) + n_processes: number of processes to work on the chunks (more processes increase memory usage) + pbar_position: deprecated + multiprocessing_point_threshold: number of raster cells at which multiprocessing should be started. For + relatively small datasets, the overhead of spawning extra processes outweights the benefit. Ideal setting + depends on the features that are calculated + metric_options: see :func:`calc_custom_metrics` + + Returns: + An :class:`xarray.Dataset` containing the metric(s) in a raster grid + + """ if not isinstance(metrics, list): metrics = [metrics] + if metric_options is None: + metric_options = [dict()] * len(metrics) # if there are actually rather few voxels (e.g., < 10,000), single thread is faster due to less overhead if len(self.XVoxelCenter[0]) < multiprocessing_point_threshold: - return self.calc_custom_metrics(metrics=metrics, pbar_position=pbar_position, progressbaropts={ - 'desc': 'Computing raster metrics ( Single Process)', - 'ncols': 150, - 'leave': False, - 'colour': '#94f19b' - }) + return self.calc_custom_metrics(metrics=metrics, metric_options=metric_options) num_feats = sum([len(m.get_names()) for m in metrics]) @@ -208,10 +248,9 @@ def calc_custom_metrics_parallel(self, metrics, n_chunks=16, n_processes=4, pbar outArrayType=data_arr.dtype, raster_size=self.raster_size, raster_min=self.raster_min, - perc=self.perc, - p_zabovex=self.p_zabovex, progressbar=pbarQueue, - metric = metrics) + metric = metrics, + metric_options = metric_options) pool.starmap(processing_function, zip(XVoxelCenterChunks, XVoxelContainsChunks), chunksize=1) data[:] = data_arr[:] shm.close() @@ -220,6 +259,7 @@ def calc_custom_metrics_parallel(self, metrics, n_chunks=16, n_processes=4, pbar pbarProc.kill() return self.convert_to_custom_data_array(data, metrics) + @deprecated(version="0.0.5", reason="This function is being replaced by calc_custom_metrics") def calc_metrics(self, progressbaropts=None, pbar_position=0, @@ -248,6 +288,8 @@ def convert_to_custom_data_array(self, data, metrics): # 'x': np.linspace(self.raster_min[0], self.raster_max[0], self.raster_dims[0]) + self.raster_size/2, 'val': np.concatenate([m.get_names() for m in metrics]) }) + + @deprecated(version="0.0.5", reason="This function is being replaced by convert_to_custom_data_array") def convert_to_data_array(self, data): return xarray.DataArray(data, dims=('y', 'x', 'val'), coords={'y': np.arange(self.raster_min[1], self.raster_max[1], self.raster_size) + self.raster_size/2, @@ -266,6 +308,7 @@ def convert_to_data_array(self, data): [f"pAboveX{x}Z" for x in self.p_zabovex] }) + @deprecated(version="0.0.5", reason="This function is being replaced by calc_custom_metrics_parallel") def calc_metrics_parallel(self, n_chunks=16, n_processes=4, pbar_position=0, *args, **kwargs): # if there are actually rather few voxels (e.g., < 10,000), single thread is faster due to less overhead if len(self.XVoxelCenter[0]) < 10_000: @@ -312,12 +355,16 @@ def calc_metrics_parallel(self, n_chunks=16, n_processes=4, pbar_position=0, *ar class PlotMetrics(Metrics): def __init__(self, lasfiles, plot_polygons, silent=True, pbars=True): """ + Class to calculate metrics on a plot (polygon) basis Args: - lasfiles: - plot_polygons: - silent: - pbars: + lasfiles: :class:`list` of input las-Files to consider. Note that the scanning (finding the points inside + the plots) can be sped up siginificantly by providing `.lax`-Files, which can be generated e.g. using + lasindex, part of the LASTools (https://rapidlasso.com/lastools/, proprietory software with free/open + components). + plot_polygons: :class:`geopandas.GeoDataFrame` array containing the geometries (polygons) of interest + silent: :class:`boolean` whether to print output or not + pbars: :class:`boolean` whether to display progress bars or not """ self.lasfiles = lasfiles self.plot_polygons = plot_polygons @@ -380,22 +427,17 @@ def __init__(self, lasfiles, plot_polygons, silent=True, pbars=True): inFile.scan_angle_rank[final_selection] if hasattr(inFile, 'scan_angle_rank') else inFile.scan_angle[final_selection] ), axis=0) - # for q_id, q in plot_polygons.iterrows(): - # if q.PLOT in ['PRF001', - # 'PRF002', - # 'PRF003', - # 'PRF016', - # 'PRF024', - # 'PRF205']: - # import matplotlib.pyplot as plt - # plt.figure() - # plt.scatter(self.coords[q_id][:, 0], self.coords[q_id][:, 1], - # c=self.coords[q_id][:, 2], s=0.5) - # plt.title(q.PLOT) - # plt.axis('equal') - # plt.show() - # plt.close() def calc_custom_metrics(self, metrics: MetricCalculator, metric_options=None): + """ + Calculates given metrics for points contained in the polygons given during construction of this class. + Args: + metrics: a single :class:`pyForMetrix.metricCalculators.MetricCalculator` instance or a :class:`list` of such classes + metric_options: a :class:`list` of :class:`dict`s with options (kwargs) for each `MetricCalculator`, or None. + + Returns: + a :class:`pandas.DataFrame` containing the metrics for each polygon in the input. + + """ if metric_options is None: metric_options = dict() out_metrics = np.full((len(self.plot_polygons), sum(map(lambda x: len(x.get_names()), metrics))), np.nan) @@ -448,6 +490,8 @@ def calc_custom_metrics_stripwise(self, metrics: MetricCalculator, metric_option print(' [done]') return out_data, out_meta + @deprecated(version="0.0.5", reason="This function is being replaced by calc_custom_metrics") + def calc_metrics(self): out_metrics = np.full((len(self.plot_polygons), 8 + len(self.perc) + len(self.p_zabovex)), np.nan) # plot_names = [] From 0d83f0760c2154c62a6132417d2779a6b46fd9a7 Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:31:48 -0800 Subject: [PATCH 02/13] updated mock imports --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 79394d6..08fff63 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,7 +6,7 @@ autodoc_mock_imports = ['xarray', 'pandas', 'numpy', 'scipy', 'laxpy', 'tqdm', - 'matplotlib', 'shapely'] + 'matplotlib', 'shapely', 'deprecated'] # -- Project information from sphinx_pyproject import SphinxConfig From df937bb56b2bfc787848b19a433956bc7094c9e3 Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:33:40 -0800 Subject: [PATCH 03/13] updated mock imports --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 08fff63..ac85a79 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -5,7 +5,7 @@ sys.path.insert(0, os.path.abspath('../../src')) autodoc_mock_imports = ['xarray', 'pandas', 'numpy', - 'scipy', 'laxpy', 'tqdm', + 'scipy', 'laxpy', 'tqdm', 'laspy' 'matplotlib', 'shapely', 'deprecated'] # -- Project information From 244f1f1284f8acabca1c67595917b7e66f2860e1 Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:35:53 -0800 Subject: [PATCH 04/13] updated imports --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 1104dd8..0d9e252 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ m2r2 -sphinx_pyproject \ No newline at end of file +sphinx_pyproject +matplotlib \ No newline at end of file From 7d4660dd7e1c69183756025ba7167344bcd0685f Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:41:04 -0800 Subject: [PATCH 05/13] updated imports --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 0d9e252..2e065b2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ m2r2 sphinx_pyproject -matplotlib \ No newline at end of file +matplotlib +numpy \ No newline at end of file From 1809285478031fb30faaab3db3f5e3e068213f3f Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:44:54 -0800 Subject: [PATCH 06/13] updated imports --- docs/requirements.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 2e065b2..1104dd8 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,2 @@ m2r2 -sphinx_pyproject -matplotlib -numpy \ No newline at end of file +sphinx_pyproject \ No newline at end of file From 7ccd088084da5f31090d34afe5070b7d335d510e Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:47:39 -0800 Subject: [PATCH 07/13] added agg backend for matplotlib in docs --- docs/source/conf.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index ac85a79..99a24f4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -53,4 +53,8 @@ html_theme = 'sphinx_rtd_theme' # -- Options for EPUB output -epub_show_urls = 'footnote' \ No newline at end of file +epub_show_urls = 'footnote' + + +import matplotlib +matplotlib.use('agg') \ No newline at end of file From 4e7970dd9532e7ff7e269e294c1b92ee45022f9a Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:49:36 -0800 Subject: [PATCH 08/13] updates matplotlib in docs --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 1104dd8..0d9e252 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ m2r2 -sphinx_pyproject \ No newline at end of file +sphinx_pyproject +matplotlib \ No newline at end of file From e2ad98b9100cdae4d9a7cf2eb71062557a12f640 Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:52:30 -0800 Subject: [PATCH 09/13] updates mock imports in docs --- docs/source/conf.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 99a24f4..828ebc5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -3,10 +3,6 @@ import os import sys sys.path.insert(0, os.path.abspath('../../src')) - -autodoc_mock_imports = ['xarray', 'pandas', 'numpy', - 'scipy', 'laxpy', 'tqdm', 'laspy' - 'matplotlib', 'shapely', 'deprecated'] # -- Project information from sphinx_pyproject import SphinxConfig @@ -20,6 +16,10 @@ # release = '0.0' # version = '0.0.1a' + +autodoc_mock_imports = ['xarray', 'pandas', 'numpy', + 'scipy', 'laxpy', 'tqdm', 'laspy' + 'matplotlib', 'shapely', 'deprecated'] # -- General configuration extensions = [ From ed98847fb8e8a318ea47d9802c3f934090dceb6d Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:53:01 -0800 Subject: [PATCH 10/13] updates mock imports in docs --- docs/requirements.txt | 3 +-- docs/source/conf.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 0d9e252..1104dd8 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,2 @@ m2r2 -sphinx_pyproject -matplotlib \ No newline at end of file +sphinx_pyproject \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 828ebc5..cf300fa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,7 +18,7 @@ autodoc_mock_imports = ['xarray', 'pandas', 'numpy', - 'scipy', 'laxpy', 'tqdm', 'laspy' + 'scipy', 'laxpy', 'tqdm', 'laspy', 'matplotlib', 'shapely', 'deprecated'] # -- General configuration From b89a0a2aef815ac390f0359fd21df78e6bd6dd1c Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:54:35 -0800 Subject: [PATCH 11/13] updates mock imports in docs --- docs/source/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index cf300fa..efc3b5c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -55,6 +55,6 @@ # -- Options for EPUB output epub_show_urls = 'footnote' - -import matplotlib -matplotlib.use('agg') \ No newline at end of file +# +# import matplotlib +# matplotlib.use('agg') \ No newline at end of file From f2b6834b4915e23903eb004f56c57f515cc0f877 Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 12:58:19 -0800 Subject: [PATCH 12/13] fixed typo --- src/pyForMetrix/metrix.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pyForMetrix/metrix.py b/src/pyForMetrix/metrix.py index 8e961ff..85d0c24 100644 --- a/src/pyForMetrix/metrix.py +++ b/src/pyForMetrix/metrix.py @@ -430,6 +430,7 @@ def __init__(self, lasfiles, plot_polygons, silent=True, pbars=True): def calc_custom_metrics(self, metrics: MetricCalculator, metric_options=None): """ Calculates given metrics for points contained in the polygons given during construction of this class. + Args: metrics: a single :class:`pyForMetrix.metricCalculators.MetricCalculator` instance or a :class:`list` of such classes metric_options: a :class:`list` of :class:`dict`s with options (kwargs) for each `MetricCalculator`, or None. From b005b1b4494083642102c9bc06fe09a997059397 Mon Sep 17 00:00:00 2001 From: Lukas Winiwarter Date: Tue, 29 Nov 2022 17:26:36 -0800 Subject: [PATCH 13/13] added pytests and .lax-File for Netzkater demo data --- demo/las_623_5718_1_th_2014-2019.lax | Bin 0 -> 34860 bytes .../metricCalculators/publications.py | 2 +- tests/test_plot_metrics.py | 62 ++++++++++++++++++ tests/test_raster_metrics.py | 27 ++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 demo/las_623_5718_1_th_2014-2019.lax create mode 100644 tests/test_plot_metrics.py create mode 100644 tests/test_raster_metrics.py diff --git a/demo/las_623_5718_1_th_2014-2019.lax b/demo/las_623_5718_1_th_2014-2019.lax new file mode 100644 index 0000000000000000000000000000000000000000..dece799edef3ab6de8916c4d2efc94c5479825f3 GIT binary patch literal 34860 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
    rKk0MC#(<_7hO{X&WX%#I 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 P&D>sUB(YD|u}}XGg49-2 literal 0 HcmV?d00001 diff --git a/src/pyForMetrix/metricCalculators/publications.py b/src/pyForMetrix/metricCalculators/publications.py index 71bac4c..9d9b936 100644 --- a/src/pyForMetrix/metricCalculators/publications.py +++ b/src/pyForMetrix/metricCalculators/publications.py @@ -78,7 +78,7 @@ def __call__(self, points_in_poly, rumple_pixel_size=1): outArray[3] = scipy.stats.kurtosis(points[:, 2]) outArray[4:6] = np.percentile(points[:, 2], [10, 90]) outArray[6] = np.count_nonzero(points[:, 2] > outArray[0]) / points.shape[0] - rumple = rumple_index(points, rumple_pixel_size) + rumple = rumple_index(points_in_poly, rumple_pixel_size) outArray[7] = rumple return outArray diff --git a/tests/test_plot_metrics.py b/tests/test_plot_metrics.py new file mode 100644 index 0000000..9021245 --- /dev/null +++ b/tests/test_plot_metrics.py @@ -0,0 +1,62 @@ +import geopandas, pathlib +datadir = pathlib.Path(__file__).parent / '../demo' + +def ensure_netzkater_data(): + import os, wget, zipfile + if not os.path.exists(datadir / 'las_623_5718_1_th_2014-2019.laz'): + if not os.path.exists(datadir / 'data_netzkater.zip'): + print('Downloading file') + wget.download( + 'https://geoportal.geoportal-th.de/hoehendaten/LAS/las_2014-2019/las_623_5718_1_th_2014-2019.zip', + str((datadir / 'data_netzkater.zip').absolute())) + print('Unzipping file') + zipfile.ZipFile(str((datadir / 'data_netzkater.zip').absolute())).extractall(str(datadir.absolute())) + + +def test_paper_metrics(): + from pyForMetrix.metricCalculators.publications import MCalc_Hollaus_et_al_2009, MCalc_White_et_al_2015, \ + MCalc_Xu_et_al_2019, MCalc_Woods_et_al_2009 + from pyForMetrix.metrix import PlotMetrics + ensure_netzkater_data() + polys = geopandas.read_file(datadir / 'netzkater_polygons.gpkg') + pm = PlotMetrics([datadir / 'las_623_5718_1_th_2014-2019.laz'], polys) + mcs = [MCalc_Hollaus_et_al_2009(), MCalc_White_et_al_2015(), MCalc_Xu_et_al_2019(), MCalc_Woods_et_al_2009()] + results = pm.calc_custom_metrics(mcs) + assert results.shape == (7,62) + print(results) + +def test_lidRmetrics(): + from pyForMetrix.metricCalculators.types import \ + MCalc_lidRmetrics_lad, \ + MCalc_lidRmetrics_kde, \ + MCalc_lidRmetrics_dispersion, \ + MCalc_lidRmetrics_voxels, \ + MCalc_lidRmetrics_HOME, \ + MCalc_lidRmetrics_percabove, \ + MCalc_lidRmetrics_echo, \ + MCalc_lidRmetrics_basic, \ + MCalc_lidRmetrics_Lmoments, \ + MCalc_lidRmetrics_rumple, \ + MCalc_lidRmetrics_percentiles, \ + MCalc_lidRmetrics_interval, \ + MCalc_lidRmetrics_canopydensity + + from pyForMetrix.metrix import PlotMetrics + ensure_netzkater_data() + polys = geopandas.read_file(datadir / 'netzkater_polygons.gpkg') + pm = PlotMetrics([datadir / 'las_623_5718_1_th_2014-2019.laz'], polys) + mcs = [MCalc_lidRmetrics_lad(), + MCalc_lidRmetrics_kde(), + MCalc_lidRmetrics_dispersion(), + MCalc_lidRmetrics_voxels(), + MCalc_lidRmetrics_HOME(), + MCalc_lidRmetrics_percabove(), + MCalc_lidRmetrics_echo(), + MCalc_lidRmetrics_basic(), + MCalc_lidRmetrics_rumple(), + MCalc_lidRmetrics_percentiles(), + MCalc_lidRmetrics_interval(), + MCalc_lidRmetrics_canopydensity()] + results = pm.calc_custom_metrics(mcs) + assert results.shape == (7,78) + print(results) \ No newline at end of file diff --git a/tests/test_raster_metrics.py b/tests/test_raster_metrics.py new file mode 100644 index 0000000..5fbd9e3 --- /dev/null +++ b/tests/test_raster_metrics.py @@ -0,0 +1,27 @@ +from test_plot_metrics import ensure_netzkater_data, datadir + + +def test_group_metrics(): + from pyForMetrix.metricCalculators.types import MCalc_VisMetrics, MCalc_DensityMetrics, \ + MCalc_HeightMetrics, MCalc_EchoMetrics, MCalc_CoverMetrics, MCalc_VarianceMetrics + from pyForMetrix.metrix import RasterMetrics + from pyForMetrix.normalizer import normalize + ensure_netzkater_data() + import laspy + data = laspy.read(datadir / 'las_623_5718_1_th_2014-2019.laz') + points = { + 'points': data.xyz, + 'classification': data.classification, + 'echo_number': data.return_number, + 'scan_angle_rank': data.scan_angle_rank, + 'pt_src_id': data.point_source_id + } + normalize(points) + rm = RasterMetrics(points, raster_size=25) + mcs = [MCalc_EchoMetrics(), MCalc_DensityMetrics(), MCalc_CoverMetrics(), + MCalc_VarianceMetrics(), MCalc_HeightMetrics(), MCalc_VisMetrics()] + results = rm.calc_custom_metrics(mcs) + assert results.shape == (40, 40, 35) + assert abs(results.sel({'val':'p100'}).data.max() - 105.63799999) < 0.0001 + + print(results)