├── binder ├── environment.yml ├── extra-environment.yml └── postBuild ├── mypy.ini ├── examples ├── analysis │ ├── array_numerics │ │ ├── README.rst │ │ ├── numpy_interfacing.py │ │ └── python_arithmetic.py │ ├── distance_ops │ │ ├── README.rst │ │ ├── buffer_voronoi.py │ │ └── proximity_metric.py │ └── README.rst ├── io │ ├── import_export │ │ ├── README.rst │ │ ├── import_vector.py │ │ ├── pc_from_xyz.py │ │ ├── import_raster.py │ │ └── raster_from_array.py │ ├── open_save │ │ ├── README.rst │ │ ├── read_satimg.py │ │ ├── read_vector.py │ │ ├── read_pointcloud.py │ │ └── read_raster.py │ └── README.rst └── handling │ ├── georeferencing │ ├── README.rst │ ├── reproj_vector.py │ ├── crop_raster.py │ ├── crop_vector.py │ └── reproj_raster.py │ ├── raster_point │ ├── README.rst │ ├── topoints.py │ ├── gridding.py │ ├── interpolation.py │ └── reduction.py │ ├── raster_vector │ ├── README.rst │ ├── polygonize.py │ ├── create_mask.py │ └── rasterize.py │ └── README.rst ├── .relint.yml ├── doc ├── source │ ├── _static │ │ ├── logo.png │ │ ├── logo_only.png │ │ └── css │ │ │ └── custom.css │ ├── imgs │ │ ├── profiling_time_graph.png │ │ └── profiling_memory_my_program.png │ ├── credits.md │ ├── data_object_index.md │ ├── cli.md │ ├── core_index.md │ ├── sphinxext.py │ ├── authors.md │ ├── code │ │ ├── about_geoutils_sidebyside_vector_geoutils.py │ │ ├── about_geoutils_sidebyside_raster_geoutils.py │ │ ├── about_geoutils_sidebyside_vector_geopandas.py │ │ └── about_geoutils_sidebyside_raster_rasterio.py │ ├── how_to_install.md │ ├── config.md │ ├── _templates │ │ └── module.rst │ ├── funding.md │ ├── mission.md │ ├── core_parsing.md │ ├── core_inheritance.md │ ├── history.md │ ├── core_lazy_load.md │ ├── distance_ops.md │ ├── index.md │ ├── quick_start.md │ ├── release_notes.md │ ├── stats.md │ └── core_py_ops.md └── Makefile ├── .coveragerc ├── setup.py ├── .github ├── dependabot.yml ├── workflows │ ├── pre-commit.yml │ ├── doc-build.yml │ ├── pip-checks.yml │ ├── testpypi-publish.yml │ ├── pypi-publish.yml │ └── python-tests.yml ├── PULL_REQUEST_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── scripts │ ├── license-header.txt │ ├── apply_license_header.py │ ├── generate_yml_env_fixed_py.py │ └── generate_pip_deps_from_conda.py ├── geoutils ├── config.ini ├── pointcloud │ └── __init__.py ├── stats │ ├── __init__.py │ ├── estimators.py │ └── sampling.py ├── vector │ ├── __init__.py │ └── geotransformations.py ├── raster │ ├── distributed_computing │ │ └── __init__.py │ └── __init__.py ├── interface │ ├── __init__.py │ ├── distance.py │ └── gridding.py ├── __init__.py ├── _typing.py ├── _config.py └── examples.py ├── environment.yml ├── requirements.txt ├── .readthedocs.yaml ├── pyproject.toml ├── dev-environment.yml ├── tests ├── test_config.py ├── test_raster │ ├── test_distributing_computing │ │ └── test_cluster.py │ └── test_tiling.py ├── test_doc.py ├── test_examples.py ├── test_stats │ ├── test_sampling.py │ └── test_estimators.py ├── test_profiling.py └── test_vector │ └── test_geotransformations_vector.py ├── CONTRIBUTING.md ├── setup.cfg ├── .gitignore ├── AUTHORS.md ├── GOVERNANCE.md ├── README.md └── NOTICE /binder/environment.yml: -------------------------------------------------------------------------------- 1 | ../environment.yml -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = numpy.typing.mypy_plugin 3 | -------------------------------------------------------------------------------- /examples/analysis/array_numerics/README.rst: -------------------------------------------------------------------------------- 1 | Raster numerics 2 | --------------- 3 | -------------------------------------------------------------------------------- /examples/io/import_export/README.rst: -------------------------------------------------------------------------------- 1 | Import and export 2 | ----------------- 3 | -------------------------------------------------------------------------------- /examples/analysis/distance_ops/README.rst: -------------------------------------------------------------------------------- 1 | Distance estimation 2 | ------------------- 3 | -------------------------------------------------------------------------------- /examples/handling/georeferencing/README.rst: -------------------------------------------------------------------------------- 1 | Geo-transformations 2 | ------------------- 3 | -------------------------------------------------------------------------------- /examples/io/open_save/README.rst: -------------------------------------------------------------------------------- 1 | Open and save from files 2 | ------------------------ 3 | -------------------------------------------------------------------------------- /.relint.yml: -------------------------------------------------------------------------------- 1 | - name: Type hint in docstring 2 | pattern: ':[r]?type ' 3 | filePattern: .*\.py 4 | -------------------------------------------------------------------------------- /examples/handling/raster_point/README.rst: -------------------------------------------------------------------------------- 1 | Raster–point interfacing 2 | ------------------------ 3 | -------------------------------------------------------------------------------- /examples/handling/raster_vector/README.rst: -------------------------------------------------------------------------------- 1 | Raster–vector interfacing 2 | ------------------------- 3 | -------------------------------------------------------------------------------- /binder/extra-environment.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | dependencies: 4 | - jupytext 5 | - myst-nb 6 | -------------------------------------------------------------------------------- /doc/source/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GlacioHack/geoutils/HEAD/doc/source/_static/logo.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: not covered 4 | @overload 5 | except ImportError 6 | -------------------------------------------------------------------------------- /doc/source/_static/logo_only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GlacioHack/geoutils/HEAD/doc/source/_static/logo_only.png -------------------------------------------------------------------------------- /doc/source/imgs/profiling_time_graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GlacioHack/geoutils/HEAD/doc/source/imgs/profiling_time_graph.png -------------------------------------------------------------------------------- /doc/source/imgs/profiling_memory_my_program.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GlacioHack/geoutils/HEAD/doc/source/imgs/profiling_memory_my_program.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """This file now only serves for backward-compatibility for routines explicitly calling python setup.py""" 2 | 3 | from setuptools import setup 4 | 5 | setup() 6 | -------------------------------------------------------------------------------- /doc/source/credits.md: -------------------------------------------------------------------------------- 1 | (credits)= 2 | # Credits and background 3 | 4 | ```{toctree} 5 | :maxdepth: 2 6 | 7 | history 8 | mission 9 | authors 10 | funding 11 | license 12 | ``` 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | # Check for updates to GitHub Actions every week 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /examples/io/README.rst: -------------------------------------------------------------------------------- 1 | .. _examples-io: 2 | 3 | Input/output 4 | ============ 5 | 6 | Examples about **opening**, **creating**, **loading** or **saving** geospatial data. 7 | 8 | With :class:`Rasters`, data is only loaded when necessary (see :ref:`core-lazy-load`). 9 | -------------------------------------------------------------------------------- /geoutils/config.ini: -------------------------------------------------------------------------------- 1 | # Default global parameters for GeoUtils 2 | 3 | [raster] 4 | 5 | # Shift by half a pixel for "Point" pixel interpretation during raster-point operations 6 | shift_area_or_point = True 7 | 8 | # Raise a warning if two rasters have different pixel interpretation 9 | warn_area_or_point = True 10 | -------------------------------------------------------------------------------- /doc/source/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Work around to wrong dark-mode for toggle button: https://github.com/executablebooks/MyST-NB/issues/523 */ 2 | div.cell details.hide > summary { 3 | background-color: var(--pst-color-surface); 4 | } 5 | 6 | div.cell details[open].above-input div.cell_input { 7 | border-top: None; 8 | } 9 | -------------------------------------------------------------------------------- /doc/source/data_object_index.md: -------------------------------------------------------------------------------- 1 | (data-object-index)= 2 | # Geospatial data objects 3 | 4 | Prefer to learn by running examples? Explore our example galleries on {ref}`examples-io`, {ref}`examples-handling` and {ref}`examples-analysis`. 5 | 6 | ```{toctree} 7 | :maxdepth: 2 8 | 9 | raster_class 10 | vector_class 11 | pointcloud_class 12 | ``` 13 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: geoutils 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python>=3.10,<3.14 6 | - geopandas>=0.12.0 7 | - matplotlib=3.* 8 | - pyproj=3.* 9 | - rasterio>=1.3,<1.4.4 10 | - pandas>=1,<3 11 | - numpy>=1,<3 12 | - scipy=1.* 13 | - tqdm 14 | - xarray>2023 15 | - dask 16 | - rioxarray=0.* 17 | - affine 18 | - shapely 19 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: Linting and formatting (pre-commit) 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | pre-commit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | - uses: actions/setup-python@v6 15 | - uses: pre-commit/action@v3.0.1 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from environment.yml, do not modify. 2 | # See that file for comments about the need/usage of each dependency. 3 | 4 | geopandas>=0.12.0 5 | matplotlib==3.* 6 | pyproj==3.* 7 | rasterio>=1.3,<1.4.4 8 | pandas>=1,<3 9 | numpy>=1,<3 10 | scipy==1.* 11 | tqdm 12 | xarray>2023 13 | dask 14 | rioxarray==0.* 15 | affine 16 | shapely 17 | -------------------------------------------------------------------------------- /doc/source/cli.md: -------------------------------------------------------------------------------- 1 | (cli)= 2 | # Command line interface 3 | 4 | This page lists all command line interface (CLI) functionalities of GeoUtils. 5 | These commands can be run directly from a terminal, without having to launch a Python console. 6 | 7 | ## geoviewer.py 8 | 9 | ```{eval-rst} 10 | .. argparse:: 11 | :filename: geoviewer.py 12 | :func: getparser 13 | :prog: geoviewer.py 14 | ``` 15 | -------------------------------------------------------------------------------- /doc/source/core_index.md: -------------------------------------------------------------------------------- 1 | (core-index)= 2 | # Fundamentals 3 | 4 | Prefer to learn by running examples? Explore our example galleries on {ref}`examples-io`, {ref}`examples-handling` and {ref}`examples-analysis`. 5 | 6 | ```{toctree} 7 | :maxdepth: 2 8 | 9 | core_composition 10 | core_match_ref 11 | core_py_ops 12 | core_array_funcs 13 | core_lazy_load 14 | core_parsing 15 | core_inheritance 16 | ``` 17 | -------------------------------------------------------------------------------- /doc/source/sphinxext.py: -------------------------------------------------------------------------------- 1 | """Functions for documentation configuration only, importable by sphinx""" 2 | 3 | 4 | # To reset resolution setting for each sphinx-gallery example 5 | def reset_mpl(gallery_conf, fname): 6 | # To get a good resolution for displayed figures 7 | from matplotlib import pyplot 8 | 9 | pyplot.rcParams["figure.dpi"] = 600 10 | pyplot.rcParams["savefig.dpi"] = 600 11 | -------------------------------------------------------------------------------- /examples/analysis/README.rst: -------------------------------------------------------------------------------- 1 | .. _examples-analysis: 2 | 3 | Analysis 4 | ======== 5 | 6 | Examples about **numerics** and **distance operations** on rasters, vectors and point clouds. 7 | 8 | Some examples rely on NumPy or pythonic operators on :class:`Rasters` (see :ref:`core-py-ops` and :ref:`core-array-funcs`). 9 | 10 | Others also benefit from match-reference (see :ref:`core-match-ref`). 11 | -------------------------------------------------------------------------------- /examples/handling/README.rst: -------------------------------------------------------------------------------- 1 | .. _examples-handling: 2 | 3 | Handling 4 | ======== 5 | 6 | Examples about **geotransformations** (e.g., reprojecting, cropping), raster-vector interfacing (e.g., rasterizing, polygonizing, geometric mask) and 7 | raster-point interfacing (e.g., gridding points into a raster, interpolating or reducing a raster at points) 8 | 9 | All of these methods support simply passing a reference to match (see :ref:`core-match-ref`). 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [ ] Resolves #xxx, 4 | - [ ] Tests added, otherwise issue #xxx opened, 5 | - [ ] Fully documented, including `api/*.md` for new API. 6 | - [ ] New optional dependencies or Python version support added to both `dev-environment.yml` and `setup.cfg`, 7 | - [ ] If contributor workflow (test, doc, linting) or Python version support changed, update `CONTRIBUTING.md`. 8 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # To avoid silent errors 3 | 4 | # ${MAMBA_EXE} env update -p ${NB_PYTHON_PREFIX} --file "environment.yml" 5 | pip install -e . 6 | ${MAMBA_EXE} env update -p ${NB_PYTHON_PREFIX} --file "binder/extra-environment.yml" 7 | wget https://raw.githubusercontent.com/mwouts/jupytext/main/binder/labconfig/default_setting_overrides.json -P ~/.jupyter/labconfig/ # To automatically open Markdown files as notebooks with Jupytext, see https://github.com/mwouts/jupytext 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | os: "ubuntu-20.04" 10 | tools: 11 | python: "mambaforge-4.10" 12 | 13 | # Build documentation in the doc/ directory with Sphinx 14 | sphinx: 15 | configuration: doc/source/conf.py 16 | fail_on_warning: false 17 | 18 | # Build the doc in offline formats 19 | formats: 20 | - pdf 21 | - htmlzip 22 | 23 | conda: 24 | environment: dev-environment.yml 25 | -------------------------------------------------------------------------------- /.github/workflows/doc-build.yml: -------------------------------------------------------------------------------- 1 | name: documentation 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | doc: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v6 14 | - uses: actions/setup-python@v6 15 | with: 16 | python-version: '3.12' 17 | - name: Install project with documentation dependencies 18 | run: python -m pip install .[doc,opt] -vv 19 | - name: Sphinx build 20 | run: | 21 | cd doc 22 | sphinx-build -b html source/ build/ 23 | -------------------------------------------------------------------------------- /doc/source/authors.md: -------------------------------------------------------------------------------- 1 | (authors)= 2 | # Authors 3 | 4 | © 2025 **GeoUtils developers**. 5 | 6 | **GeoUtils** is licensed under permissive Apache 2 license (See [LICENSE file](license.md) or below). 7 | 8 | All contributors listed in this document are part of the **GeoUtils developers**, and their 9 | contributions are subject to the project's copyright under the terms of the 10 | [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). 11 | 12 | Please refer to [AUTHORS file](https://github.com/GlacioHack/geoutils/blob/main/AUTHORS.md) for the complete and detailed list of authors and their contributions. 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # Minimum requirements for the build system to execute. 3 | requires = [ 4 | "setuptools>=64", 5 | "setuptools_scm[toml]>=8,<9.0", 6 | "wheel", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | # To write version to file 11 | [tool.setuptools_scm] 12 | version_file = "geoutils/_version.py" 13 | fallback_version = "0.0.1" 14 | # Use no-local-version by default for CI builds 15 | local_scheme = "no-local-version" 16 | 17 | [tool.black] 18 | target_version = ['py310'] 19 | 20 | [tool.pytest.ini_options] 21 | addopts = "--doctest-modules -W error::UserWarning" 22 | testpaths = ["tests", "geoutils"] 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | [Please provide a code sample here.] 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | 21 | **System (please complete the following information):** 22 | - OS: [e.g. iOS] 23 | - Environment [e.g. contents of conda environment] 24 | 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/scripts/license-header.txt: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | -------------------------------------------------------------------------------- /examples/io/open_save/read_satimg.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parsing sensor metadata 3 | ======================= 4 | 5 | This example demonstrates the instantiation of a raster while parsing image sensor metadata. 6 | """ 7 | 8 | import geoutils as gu 9 | 10 | # %% 11 | # We print the filename of our raster that, as often with satellite data, holds metadata information. 12 | filename_geoimg = gu.examples.get_path("everest_landsat_b4") 13 | import os 14 | 15 | print(os.path.basename(filename_geoimg)) 16 | 17 | # %% 18 | # We open it as a raster with the option to parse metadata, un-silencing the attribute retrieval to see it printed. 19 | img = gu.Raster(filename_geoimg, parse_sensor_metadata=True, silent=False) 20 | 21 | # %% 22 | # We have now retrieved the metadata, stored in the :attr:`geoutils.Raster.tags` attribute. 23 | img.tags 24 | -------------------------------------------------------------------------------- /geoutils/pointcloud/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | from geoutils.pointcloud.pointcloud import PointCloud # noqa 20 | -------------------------------------------------------------------------------- /examples/io/import_export/import_vector.py: -------------------------------------------------------------------------------- 1 | """ 2 | From/to GeoPandas 3 | ================= 4 | 5 | This example demonstrates importing or exporting a :class:`geopandas.GeoDataFrame` from and to a :class:`~geoutils.Vector`. 6 | """ 7 | 8 | # %% 9 | # A vector can be imported from a :class:`geopandas.GeoDataFrame` simply by instantiating :class:`~geoutils.Vector`. 10 | 11 | import geopandas as gpd 12 | 13 | import geoutils as gu 14 | 15 | filename_vect = gu.examples.get_path("exploradores_rgi_outlines") 16 | ds = gpd.read_file(filename_vect) 17 | vect = gu.Vector(ds) 18 | vect 19 | 20 | # %% 21 | # We plot the vector. 22 | 23 | vect.plot(column="RGIId", add_cbar=False) 24 | 25 | # %% 26 | # To export, the :class:`geopandas.GeoDataFrame` is always stored as an attribute as :class:`~geoutils.Vector` is composed from it. See :ref:`core-composition`. 27 | 28 | vect.ds 29 | -------------------------------------------------------------------------------- /geoutils/stats/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | from geoutils.stats.estimators import * # noqa 20 | from geoutils.stats.sampling import * # noqa 21 | from geoutils.stats.stats import * # noqa 22 | -------------------------------------------------------------------------------- /examples/handling/raster_point/topoints.py: -------------------------------------------------------------------------------- 1 | """ 2 | Raster to regular points 3 | ======================== 4 | 5 | This example demonstrates the conversion of a raster regular-grid values to a point cloud using :func:`geoutils.Raster.to_points`. 6 | """ 7 | 8 | # %% 9 | # We open a raster. 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("exploradores_aster_dem") 15 | rast = gu.Raster(filename_rast) 16 | rast = rast.crop([rast.bounds.left, rast.bounds.bottom, rast.bounds.left + 500, rast.bounds.bottom + 500]) 17 | 18 | # %% 19 | # Let's plot the raster. 20 | rast.plot(cmap="terrain") 21 | 22 | # %% 23 | # We convert the raster to points. By default, this returns a vector with column geometry burned. 24 | 25 | pc = rast.to_pointcloud() 26 | pc 27 | 28 | # %% 29 | # We plot the point vector. 30 | 31 | pc.plot(ax="new", cmap="terrain", legend=True) 32 | -------------------------------------------------------------------------------- /geoutils/vector/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | from geoutils.vector.geometric import * # noqa 20 | from geoutils.vector.geotransformations import * # noqa 21 | from geoutils.vector.vector import Vector, VectorType # noqa 22 | -------------------------------------------------------------------------------- /.github/workflows/pip-checks.yml: -------------------------------------------------------------------------------- 1 | # This workflow checks that pip installation works to import the package (tests are in python-tests.yml) 2 | 3 | name: pip-install 4 | 5 | on: 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | test: 13 | name: ${{ matrix.os }}, python ${{ matrix.python-version }} 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | matrix: 18 | os: ["ubuntu-latest", "macos-latest"] 19 | python-version: ["3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v6 23 | 24 | - uses: actions/setup-python@v6 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install project 29 | run: python -m pip install . -vv 30 | 31 | # Check import works 32 | - name: Check import works with base environment 33 | run: python -c "import geoutils" 34 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS ?= 6 | SPHINXBUILD ?= sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | clean: 17 | echo "Removing build files..." 18 | if [ -d "$(BUILDDIR)" ]; then rm -r "$(BUILDDIR)"; fi 19 | if [ -d "$(SOURCEDIR)/auto_examples" ]; then rm -r "$(SOURCEDIR)/auto_examples"; fi 20 | if [ -d "$(SOURCEDIR)/gen_modules" ]; then rm -r "$(SOURCEDIR)/gen_modules"; fi 21 | 22 | # Catch-all target: route all unknown targets to Sphinx using the new 23 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 24 | %: Makefile 25 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 26 | -------------------------------------------------------------------------------- /geoutils/raster/distributed_computing/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 Centre National d'Etudes Spatiales (CNES) 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | from geoutils.raster.distributed_computing.cluster import * # noqa 20 | from geoutils.raster.distributed_computing.multiproc import * # noqa 21 | -------------------------------------------------------------------------------- /geoutils/interface/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | from geoutils.interface.distance import * # noqa 20 | from geoutils.interface.gridding import * # noqa 21 | from geoutils.interface.interpolate import * # noqa 22 | from geoutils.interface.raster_point import * # noqa 23 | from geoutils.interface.raster_vector import * # noqa 24 | -------------------------------------------------------------------------------- /doc/source/code/about_geoutils_sidebyside_vector_geoutils.py: -------------------------------------------------------------------------------- 1 | #### 2 | # Part not shown in the doc, to get data files ready 3 | import geoutils 4 | 5 | landsat_b4_path = geoutils.examples.get_path("everest_landsat_b4") 6 | everest_outlines_path = geoutils.examples.get_path("everest_rgi_outlines") 7 | geoutils.Raster(landsat_b4_path).to_file("myraster.tif") 8 | geoutils.Vector(everest_outlines_path).to_file("myvector.gpkg") 9 | #### 10 | 11 | import geoutils as gu 12 | 13 | # Opening a vector and a raster 14 | vect = gu.Vector("myvector.gpkg") 15 | rast = gu.Raster("myraster.tif") 16 | 17 | # Metric buffering 18 | vect_buff = vect.buffer_metric(buffer_size=100) 19 | 20 | # Create a mask on the raster grid 21 | # (raster not loaded, only metadata) 22 | mask = vect_buff.create_mask(rast) 23 | 24 | # Index raster values on mask 25 | # (raster loads implicitly) 26 | values = rast[mask] 27 | 28 | #### 29 | # Part not shown in the doc, to get data files ready 30 | import os 31 | 32 | for file in ["myraster.tif", "myvector.gpkg"]: 33 | os.remove(file) 34 | #### 35 | -------------------------------------------------------------------------------- /examples/handling/raster_point/gridding.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gridding points to raster 3 | ========================= 4 | 5 | This example demonstrates the gridding of a point cloud into a raster using :func:`~geoutils.PointCloud.gridding`. 6 | """ 7 | 8 | # %% 9 | # We open an example point cloud, an elevation dataset in New Zealand. 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_pc = gu.examples.get_path("coromandel_lidar") 15 | pc = gu.PointCloud(filename_pc, data_column="Z") 16 | 17 | # Plot the point cloud 18 | pc.plot(cmap="terrain", cbar_title="Elevation (m)") 19 | 20 | # %% 21 | # We generate grid coordinates to interpolate to, alternatively we could pass a raster to use as reference. 22 | 23 | import numpy as np 24 | 25 | grid_coords = (np.linspace(pc.bounds.left, pc.bounds.right, 100), np.linspace(pc.bounds.bottom, pc.bounds.top, 100)) 26 | 27 | # %% 28 | # We then perform the interpolation 29 | rast = pc.grid(grid_coords=grid_coords) 30 | 31 | # %% 32 | # Finally, we plot the resulting raster 33 | 34 | rast.plot(ax="new", cmap="terrain", cbar_title="Elevation (m)") 35 | -------------------------------------------------------------------------------- /examples/io/open_save/read_vector.py: -------------------------------------------------------------------------------- 1 | """ 2 | Open/save a vector 3 | ================== 4 | 5 | This example demonstrates the instantiation of a vector through :class:`geoutils.Vector` and saving with :func:`~geoutils.Vector.save`. 6 | """ 7 | 8 | import geoutils as gu 9 | 10 | # %% 11 | # We open an example vector. 12 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 13 | vect = gu.Vector(filename_vect) 14 | vect 15 | 16 | # %% 17 | # A vector is composed of a single main attribute: a :class:`~geoutils.Vector.ds` geodataframe. 18 | # All other attributes are :ref:`inherited from Shapely and GeoPandas`. See also :ref:`vector-class`. 19 | 20 | # %% 21 | # 22 | # .. note:: 23 | # A vector can also be instantiated with a :class:`geopandas.GeoDataFrame`, see :ref:`sphx_glr_io_examples_import_export_import_vector.py`. 24 | # 25 | # We can print more info on the vector. 26 | vect.info() 27 | 28 | # %% 29 | # Let's plot by vector area 30 | vect.plot(column="Area", cbar_title="Area (km²)") 31 | 32 | # %% 33 | # Finally, a vector is saved using :func:`~geoutils.Vector.save`. 34 | 35 | vect.to_file("myvector.gpkg") 36 | -------------------------------------------------------------------------------- /.github/scripts/apply_license_header.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Path to the license header 4 | HEADER_FILE = os.path.join(os.path.dirname(__file__), "license-header.txt") 5 | 6 | # read license header 7 | with open(HEADER_FILE) as file: 8 | license_header = file.read() 9 | 10 | 11 | # Add license header to a file 12 | def add_license_header(file_path, header): 13 | with open(file_path) as f: 14 | content = f.read() 15 | 16 | # Check if the header is already there 17 | if content.startswith(header): 18 | return 19 | 20 | # If not, add it 21 | with open(file_path, "w") as f: 22 | f.write(header + "\n" + content) 23 | print(f"Header added to {file_path}") 24 | 25 | 26 | # Check the header in every file in root_dir 27 | def apply_license_header_to_all_py_files(root_dir): 28 | for subdir, _, files in os.walk(root_dir): 29 | for file in files: 30 | if file.endswith(".py"): 31 | file_path = os.path.join(subdir, file) 32 | add_license_header(file_path, license_header) 33 | 34 | 35 | # Source directory 36 | PROJECT_SRC = "geoutils" 37 | 38 | # Add header to every source files 39 | apply_license_header_to_all_py_files(PROJECT_SRC) 40 | -------------------------------------------------------------------------------- /doc/source/how_to_install.md: -------------------------------------------------------------------------------- 1 | (how-to-install)= 2 | 3 | # How to install 4 | 5 | ## Installing with ``mamba`` (recommended) 6 | 7 | ```bash 8 | mamba install -c conda-forge geoutils 9 | ``` 10 | 11 | ```{tip} 12 | Solving dependencies can take a long time with `conda`, `mamba` significantly speeds up the process. Install it with: 13 | 14 | conda install mamba -n base -c conda-forge 15 | 16 | Once installed, the same commands can be run by simply replacing `conda` by `mamba`. More details available in the [mamba documentation](https://mamba.readthedocs.io/en/latest/). 17 | ``` 18 | 19 | ## Installing with ``pip`` 20 | 21 | ```bash 22 | pip install geoutils 23 | ``` 24 | 25 | ```{warning} 26 | Updating packages with `pip` (and sometimes `mamba`) can break your installation. If this happens, re-create an environment from scratch pinning directly all your other dependencies during initial solve (e.g., `mamba create -n geoutils-env -c conda-forge geoutils myotherpackage==1.0.0`). 27 | ``` 28 | 29 | ## Installing for contributors 30 | 31 | ```bash 32 | git clone https://github.com/GlacioHack/geoutils.git 33 | mamba env create -f geoutils/dev-environment.yml 34 | ``` 35 | 36 | After installing, you can check that everything is working by running the tests: `pytest -rA`. 37 | -------------------------------------------------------------------------------- /examples/io/open_save/read_pointcloud.py: -------------------------------------------------------------------------------- 1 | """ 2 | Open/save a point cloud 3 | ======================= 4 | 5 | This example demonstrates the instantiation of a point cloud through :class:`geoutils.PointCloud` and saving with :func:`~geoutils.Vector.save`. 6 | """ 7 | 8 | import geoutils as gu 9 | 10 | # %% 11 | # We open an example vector. 12 | filename_pc = gu.examples.get_path("coromandel_lidar") 13 | pc = gu.PointCloud(filename_pc, data_column="Z") 14 | pc 15 | 16 | # %% 17 | # A point cloud is a subclass of :class:`~geoutils.Vector`, with a main attribute :attr:`~geoutils.PointCloud.data_column` pointing to the main data column 18 | # of the point cloud. 19 | # All other attributes are :ref:`inherited from Shapely and GeoPandas`. See also :ref:`vector-class`. 20 | 21 | # %% 22 | # 23 | # .. note:: 24 | # A point cloud can also be instantiated with a :class:`geopandas.GeoDataFrame`, see :ref:`sphx_glr_io_examples_import_export_import_vector.py`. 25 | # 26 | # We can print more info on the point cloud. 27 | pc.info() 28 | 29 | # %% 30 | # Let's plot the point cloud main column 31 | pc.plot(cbar_title="Elevation (m)") 32 | 33 | # %% 34 | # Finally, a point cloud is saved using :func:`~geoutils.Vector.save`. 35 | 36 | pc.to_file("mypc.gpkg") 37 | -------------------------------------------------------------------------------- /doc/source/config.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | (config)= 14 | # Configuration 15 | 16 | You can configure the default behaviour of GeoUtils at the package level for operations that depend on user preference 17 | (such as resampling method, or pixel interpretation). 18 | 19 | ## Changing configuration during a session 20 | 21 | Using a global configuration setting ensures operations will always be performed consistently, even when used 22 | under-the-hood by higher-level methods (such as [Coregistration](https://xdem.readthedocs.io/en/stable/coregistration.html)), 23 | without having to rely on multiple keyword arguments to pass to subfunctions. 24 | 25 | ```{code-cell} 26 | import geoutils as gu 27 | # Changing default behaviour for pixel interpretation for this session 28 | gu.config["shift_area_or_point"] = False 29 | ``` 30 | 31 | ## Default configuration file 32 | 33 | Below is the full default configuration file, which is updated by changes in configuration during a session. 34 | 35 | ```{literalinclude} ../../geoutils/config.ini 36 | :class: full-width 37 | ``` 38 | -------------------------------------------------------------------------------- /examples/io/import_export/pc_from_xyz.py: -------------------------------------------------------------------------------- 1 | """ 2 | Creating a point cloud from arrays 3 | ================================== 4 | 5 | This example demonstrates the creation of a point cloud through :func:`~geoutils.Raster.from_xyz`, :func:`~geoutils.Raster.from_array` or 6 | :func:`~geoutils.Raster.from_tuples`. 7 | """ 8 | 9 | import numpy as np 10 | import pyproj 11 | 12 | # %% 13 | # We create a data array as :class:`~numpy.ndarray`, and a coordinate reference system (CRS) as :class:`pyproj.CRS`. 14 | import geoutils as gu 15 | 16 | # A random N x 3 array 17 | rng = np.random.default_rng(42) 18 | arr = rng.normal(size=(5, 3)) 19 | # A CRS, here geographic (latitude/longitude) 20 | crs = pyproj.CRS.from_epsg(4326) 21 | 22 | # Create a point cloud using three 1-d arrays 23 | pc = gu.PointCloud.from_xyz(x=arr[:, 0], y=arr[:, 1], z=arr[:, 2], crs=crs, data_column="z") 24 | pc 25 | 26 | # %% 27 | # We can print info on the point cloud. 28 | pc.info() 29 | 30 | # %% 31 | # Note that we can also use the N x 3 array directly, or also an iterable of 3-tuples 32 | pc = gu.PointCloud.from_array(arr, crs=crs, data_column="z") 33 | 34 | # %% 35 | # The different functionalities of GeoUtils will use :attr:`~geoutils.Raster.data` as default as the main data column, including plotting. 36 | pc.plot(cmap="copper") 37 | -------------------------------------------------------------------------------- /geoutils/raster/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | from geoutils.raster.raster import Raster, RasterType, handled_array_funcs # noqa isort:skip 20 | from geoutils.raster.array import * # noqa 21 | from geoutils.raster.distributed_computing import * # noqa 22 | from geoutils.raster.georeferencing import * # noqa 23 | from geoutils.raster.geotransformations import * # noqa 24 | from geoutils.raster.multiraster import * # noqa 25 | 26 | # To-be-deprecated 27 | from geoutils.raster.raster import Mask # noqa 28 | from geoutils.raster.satimg import * # noqa 29 | from geoutils.raster.tiling import * # noqa 30 | 31 | __all__ = ["RasterType", "Raster"] 32 | -------------------------------------------------------------------------------- /dev-environment.yml: -------------------------------------------------------------------------------- 1 | name: geoutils-dev 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python>=3.10,<3.14 6 | - geopandas>=0.12.0 7 | - matplotlib=3.* 8 | - pyproj=3.* 9 | - rasterio>=1.3,<1.4.4 10 | - pandas>=1,<3 11 | - numpy>=1,<3 12 | - scipy=1.* 13 | - tqdm 14 | - xarray>2023 15 | - dask 16 | - rioxarray=0.* 17 | - affine 18 | - shapely 19 | 20 | # Development-specific, to mirror manually in setup.cfg [options.extras_require]. 21 | - pip 22 | 23 | # Optional dependencies 24 | - laspy 25 | - lazrs-python # For LAZ files 26 | - scikit-image 27 | - psutil 28 | - plotly 29 | - numba 30 | 31 | # Test dependencies 32 | - gdal # To test functionalities against GDAL 33 | - pytest=7.* 34 | - pytest-xdist 35 | - pytest-lazy-fixtures 36 | - pytest-instafail 37 | - pytest-socket 38 | - pytest-cov 39 | - coveralls 40 | - pyyaml 41 | - flake8 42 | - netcdf4 # To write synthetic data with chunksizes 43 | - pre-commit 44 | 45 | # Doc dependencies 46 | - sphinx 47 | - pydata-sphinx-theme 48 | - sphinx-book-theme>=1.0 49 | - sphinx-gallery 50 | - sphinx-design 51 | - sphinx-autodoc-typehints 52 | - sphinxcontrib-programoutput 53 | - sphinx-argparse 54 | - autovizwidget 55 | - graphviz 56 | - myst-nb 57 | - numpydoc 58 | - typing-extensions 59 | 60 | - pip: 61 | - -e ./ 62 | -------------------------------------------------------------------------------- /examples/io/import_export/import_raster.py: -------------------------------------------------------------------------------- 1 | """ 2 | From/to Rasterio 3 | ================ 4 | 5 | This example demonstrates importing and exporting a :class:`rasterio.io.DatasetReader` or :class:`rasterio.io.DatasetReader` from and to a 6 | :class:`~geoutils.Raster`. 7 | """ 8 | 9 | import rasterio as rio 10 | 11 | # %% 12 | # A raster can be imported from a :class:`rasterio.io.DatasetReader` or :class:`rasterio.io.MemoryFile` simply by instantiating :class:`~geoutils.Raster`. 13 | import geoutils as gu 14 | 15 | filename_rast = gu.examples.get_path("exploradores_aster_dem") 16 | ds = rio.DatasetReader(filename_rast) 17 | rast = gu.Raster(ds) 18 | rast 19 | 20 | # %% 21 | # The data is unloaded, as when instantiated with a filename. 22 | # The data will be loaded explicitly by any function requiring its :attr:`~geoutils.Raster.data`, such as :func:`~geoutils.Raster.show`. 23 | rast.plot(cmap="terrain") 24 | 25 | # %% 26 | # We can also pass a :class:`rasterio.io.MemoryFile` during instantiation. 27 | 28 | mem = rio.MemoryFile(open(filename_rast, "rb")) 29 | rast = gu.Raster(mem) 30 | rast 31 | 32 | # %% 33 | # The data is, as expected, already in memory. 34 | # 35 | # Finally, we can export a :class:`~geoutils.Raster` to a :class:`rasterio.io.DatasetReader` of a :class:`rasterio.io.MemoryFile` using 36 | # :class:`~geoutils.Raster.to_rio_dataset` 37 | 38 | rast.to_rio_dataset() 39 | -------------------------------------------------------------------------------- /doc/source/_templates/module.rst: -------------------------------------------------------------------------------- 1 | {{ fullname }} 2 | {{ underline }} 3 | 4 | .. automodule:: {{ fullname }} 5 | 6 | .. contents:: Contents 7 | :local: 8 | 9 | {% block functions %} 10 | {% if functions %} 11 | 12 | Functions 13 | ========= 14 | 15 | {% for item in functions %} 16 | 17 | {{item}} 18 | {{ "-" * (item | length) }} 19 | 20 | .. autofunction:: {{ item }} 21 | 22 | .. _sphx_glr_backref_{{fullname}}.{{item}}: 23 | 24 | .. minigallery:: {{fullname}}.{{item}} 25 | :add-heading: 26 | 27 | {%- endfor %} 28 | {% endif %} 29 | {% endblock %} 30 | 31 | {% block classes %} 32 | {% if classes %} 33 | 34 | Classes 35 | ======= 36 | 37 | {% for item in classes %} 38 | 39 | {{item}} 40 | {{ "-" * (item | length) }} 41 | 42 | .. autoclass:: {{ item }} 43 | :show-inheritance: 44 | :special-members: __init__ 45 | :members: 46 | 47 | .. _sphx_glr_backref_{{fullname}}.{{item}}: 48 | 49 | .. minigallery:: {{fullname}}.{{item}} 50 | :add-heading: 51 | 52 | {% endfor %} 53 | {% endif %} 54 | {% endblock %} 55 | 56 | {% block exceptions %} 57 | {% if exceptions %} 58 | 59 | Exceptions 60 | ========== 61 | 62 | .. autosummary:: 63 | {% for item in exceptions %} 64 | {{ item }} 65 | {%- endfor %} 66 | {% endif %} 67 | {% endblock %} 68 | -------------------------------------------------------------------------------- /examples/analysis/distance_ops/buffer_voronoi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Metric buffer and without overlap 3 | ================================= 4 | 5 | This example demonstrates the metric buffering of a vector using :func:`~geoutils.Vector.buffer_metric` and :func:`~geoutils.Vector.buffer_without_overlap`. 6 | """ 7 | 8 | # %% 9 | # We open an example vector 10 | 11 | # sphinx_gallery_thumbnail_number = 3 12 | import geoutils as gu 13 | 14 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 15 | vect = gu.Vector(filename_vect) 16 | 17 | # %% 18 | # We buffer in metric units directly using :func:`~geoutils.Vector.buffer_metric`. Under the hood, this functionality reprojects to a local projection, 19 | # buffers, and converts back to the original CRS. 20 | vect_buff = vect.buffer_metric(buffer_size=500) 21 | 22 | # %% 23 | # Let's plot the raster and vector 24 | ax = vect.plot() 25 | vect_buff.plot(ec="k", fc="none") 26 | 27 | # %% 28 | # Many buffers are overlapping. To compute a buffer without overlap, one can use :func:`~geoutils.Vector.buffer_without_overlap`. 29 | # 30 | vect_buff_nolap = vect.buffer_without_overlap(buffer_size=500) 31 | vect.plot(ax="new") 32 | vect_buff_nolap.plot(ec="k", fc="none") 33 | 34 | # %% 35 | # We plot with color to see that the attributes are retained for every feature. 36 | vect_buff_nolap.plot(ax="new", column="Area") 37 | vect.plot(ec="k", column="Area", alpha=0.5) 38 | -------------------------------------------------------------------------------- /doc/source/code/about_geoutils_sidebyside_raster_geoutils.py: -------------------------------------------------------------------------------- 1 | #### 2 | # Part not shown in the doc, to get data files ready 3 | import geoutils 4 | 5 | landsat_b4_path = geoutils.examples.get_path("everest_landsat_b4") 6 | landsat_b4_crop_path = geoutils.examples.get_path("everest_landsat_b4_cropped") 7 | geoutils.Raster(landsat_b4_path).to_file("myraster1.tif") 8 | geoutils.Raster(landsat_b4_crop_path).to_file("myraster2.tif") 9 | import warnings 10 | 11 | warnings.filterwarnings("ignore", category=UserWarning, message="For reprojection, nodata must be set.*") 12 | warnings.filterwarnings("ignore", category=UserWarning, message="No nodata set*") 13 | warnings.filterwarnings("ignore", category=UserWarning, message="One raster has a pixel interpretation*") 14 | #### 15 | 16 | import geoutils as gu 17 | 18 | # Opening of two rasters 19 | rast1 = gu.Raster("myraster1.tif") 20 | rast2 = gu.Raster("myraster2.tif") 21 | 22 | # Reproject 1 to match 2 23 | # (raster 2 not loaded, only metadata) 24 | rast1_reproj = rast1.reproject(ref=rast2) 25 | 26 | # Array interfacing and implicit loading 27 | # (raster 2 loads implicitly) 28 | rast_result = (1 + rast2) / rast1_reproj 29 | 30 | # Saving 31 | rast_result.to_file("myresult.tif") 32 | 33 | #### 34 | # Part not shown in the docs, to clean up 35 | import os 36 | 37 | for file in ["myraster1.tif", "myraster2.tif", "myresult.tif"]: 38 | os.remove(file) 39 | #### 40 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Test configuration file.""" 2 | 3 | import geoutils as gu 4 | 5 | 6 | class TestConfig: 7 | def test_config_defaults(self) -> None: 8 | """Check defaults compared to file""" 9 | 10 | # Read file 11 | default_config = gu._config.GeoUtilsConfigDict() 12 | default_config._set_defaults(gu._config._config_ini_file) 13 | 14 | assert default_config == gu.config 15 | 16 | def test_config_set(self) -> None: 17 | """Check setting a non-default config argument by user""" 18 | 19 | # Default is True 20 | assert gu.config["shift_area_or_point"] 21 | 22 | # We set it to False and it should be updated 23 | gu.config["shift_area_or_point"] = False 24 | assert not gu.config["shift_area_or_point"] 25 | 26 | # Leave the test with the initial default 27 | gu.config["shift_area_or_point"] = True 28 | assert gu.config["shift_area_or_point"] 29 | 30 | def test_config_validator(self) -> None: 31 | """Check setting a config argument with a wrong input type converts it automatically""" 32 | 33 | # We input an "off" value, that should be converted to False 34 | gu.config["shift_area_or_point"] = "off" 35 | assert not gu.config["shift_area_or_point"] 36 | 37 | # Leave the test with initial default 38 | gu.config["shift_area_or_point"] = 1 39 | assert gu.config["shift_area_or_point"] 40 | -------------------------------------------------------------------------------- /doc/source/code/about_geoutils_sidebyside_vector_geopandas.py: -------------------------------------------------------------------------------- 1 | #### 2 | # Part not shown in the doc, to get data files ready 3 | import geoutils 4 | 5 | landsat_b4_path = geoutils.examples.get_path("everest_landsat_b4") 6 | everest_outlines_path = geoutils.examples.get_path("everest_rgi_outlines") 7 | geoutils.Raster(landsat_b4_path).to_file("myraster.tif") 8 | geoutils.Vector(everest_outlines_path).to_file("myvector.gpkg") 9 | #### 10 | 11 | import geopandas as gpd 12 | import rasterio as rio 13 | 14 | # Opening a vector and a raster 15 | df = gpd.read_file("myvector.gpkg") 16 | rast2 = rio.io.DatasetReader("myraster.tif") 17 | 18 | # Equivalent of a metric buffering 19 | # (while keeping a frame object) 20 | gs_m_crs = df.to_crs(df.estimate_utm_crs()) 21 | gs_m_crs_buff = gs_m_crs.buffer(distance=100) 22 | gs_buff = gs_m_crs_buff.to_crs(df.crs) 23 | df_buff = gpd.GeoDataFrame(geometry=gs_buff) 24 | 25 | # Equivalent of creating a rasterized mask 26 | # (ensuring CRS are similar) 27 | df_buff = df_buff.to_crs(rast2.crs) 28 | mask = rio.features.rasterize( 29 | shapes=df.geometry, fill=0, out_shape=rast2.shape, transform=rast2.transform, default_value=1, dtype="uint8" 30 | ) 31 | mask = mask.astype("bool") 32 | 33 | # Equivalent of indexing with mask 34 | values = rast2.read(1, masked=True)[mask] 35 | 36 | #### 37 | # Part not shown in the doc, to get data files ready 38 | import os 39 | 40 | for file in ["myraster.tif", "myvector.gpkg"]: 41 | os.remove(file) 42 | #### 43 | -------------------------------------------------------------------------------- /doc/source/funding.md: -------------------------------------------------------------------------------- 1 | (funding)= 2 | # Funding acknowledgments 3 | 4 | Members of the lead development team acknowledge funding from: 5 | - SNSF grant no. 184634, a MeteoSwiss [GCOS](https://gcos.wmo.int/en/home) project on elevation data analysis for glaciology, 6 | - NASA award 80NSSC22K1094, an [STV](https://science.nasa.gov/earth-science/decadal-surveys/decadal-stv/) project on the fusion of elevation data, 7 | - NASA award 80NSSC23K0192, an [ICESat-2](https://icesat-2.gsfc.nasa.gov/) project on the processing of elevation data in the cloud, 8 | - CNES (French Space Agency) award on merging [demcompare](https://github.com/CNES/demcompare) and GeoUtils/xDEM while further developing related 3D tools. 9 | 10 | 11 | ::::{grid} 12 | :reverse: 13 | 14 | :::{grid-item} 15 | :columns: 4 16 | :child-align: center 17 | 18 | ```{image} ./_static/nasa_logo.svg 19 | :width: 200px 20 | :class: dark-light 21 | ``` 22 | 23 | ::: 24 | 25 | :::{grid-item} 26 | :columns: 4 27 | :child-align: center 28 | 29 | ```{image} ./_static/snsf_logo.svg 30 | :width: 220px 31 | :class: only-light 32 | ``` 33 | 34 | ```{image} ./_static/snsf_logo_dark.svg 35 | :width: 220px 36 | :class: only-dark 37 | ``` 38 | 39 | ::: 40 | 41 | :::{grid-item} 42 | :columns: 4 43 | :child-align: center 44 | 45 | ```{image} ./_static/cnes_logo.svg 46 | :width: 200px 47 | :class: only-light 48 | ``` 49 | 50 | ```{image} ./_static/cnes_logo_dark.svg 51 | :width: 200px 52 | :class: only-dark 53 | ``` 54 | 55 | ::: 56 | 57 | 58 | :::: 59 | -------------------------------------------------------------------------------- /examples/handling/georeferencing/reproj_vector.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reproject a vector 3 | ================== 4 | 5 | This example demonstrates the reprojection of a vector using :func:`geoutils.Vector.reproject`. 6 | """ 7 | 8 | # %% 9 | # We open a raster and vector. 10 | 11 | # sphinx_gallery_thumbnail_number = 3 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("everest_landsat_b4_cropped") 15 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 16 | rast = gu.Raster(filename_rast) 17 | vect = gu.Vector(filename_vect) 18 | 19 | # %% 20 | # The two objects are in different projections. 21 | rast.info() 22 | vect.info() 23 | 24 | # %% 25 | # Let's plot the two in their original projection. 26 | rast.plot(cmap="Greys_r") 27 | vect.plot(ax="new", fc="none", ec="tab:purple", lw=3) 28 | 29 | # %% 30 | # **First option:** using the raster as a reference to match, we reproject the vector. We simply have to pass the :class:`~geoutils.Raster` as an argument 31 | # to :func:`~geoutils.Vector.reproject`. See :ref:`core-match-ref` for more details. 32 | 33 | vect_reproj = vect.reproject(rast) 34 | 35 | # %% 36 | # We can plot the vector in its new projection. 37 | 38 | vect_reproj.plot(ax="new", fc="none", ec="tab:purple", lw=3) 39 | 40 | # %% 41 | # **Second option:** we can pass the georeferencing argument ``dst_crs`` to :func:`~geoutils.Vector.reproject` (an EPSG code can be passed directly as 42 | # :class:`int`). 43 | 44 | # Reproject in UTM zone 45N. 45 | vect_reproj = vect.reproject(crs=32645) 46 | vect_reproj 47 | -------------------------------------------------------------------------------- /examples/handling/raster_vector/polygonize.py: -------------------------------------------------------------------------------- 1 | """ 2 | Polygonize a raster 3 | =================== 4 | 5 | This example demonstrates the polygonizing of a raster using :func:`geoutils.Raster.polygonize`. 6 | """ 7 | 8 | # %% 9 | # We open a raster. 10 | 11 | # sphinx_gallery_thumbnail_number = 3 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("exploradores_aster_dem") 15 | rast = gu.Raster(filename_rast) 16 | rast = rast.crop([rast.bounds.left, rast.bounds.bottom, rast.bounds.left + 5000, rast.bounds.bottom + 5000]) 17 | # %% 18 | # Let's plot the raster. 19 | rast.plot(cmap="terrain") 20 | 21 | # %% 22 | # We polygonize the raster. 23 | 24 | rast_polygonized = rast.polygonize() 25 | rast_polygonized.plot(ax="new") 26 | 27 | # %% 28 | # By default, :func:`~geoutils.Raster.polygonize` will try to polygonize target all valid values. Instead, one can specify discrete values to target by 29 | # passing a number or :class:`list`, or a range of values by passing a :class:`tuple`. 30 | 31 | # A range of values to polygonize 32 | rast_polygonized = rast.polygonize((2500, 3000)) 33 | rast_polygonized.plot(ax="new") 34 | 35 | # %% 36 | # An even simpler way to do this is to compute a boolean :func:`~geoutils.Raster` to polygonize using logical 37 | # comparisons on the :func:`~geoutils.Raster`. 38 | 39 | rast_polygonized = ((2500 < rast) & (rast < 3000)).polygonize() 40 | rast_polygonized.plot(ax="new") 41 | 42 | # %% 43 | # .. note:: 44 | # See :ref:`core-py-ops` for more details on casting to boolean :func:`~geoutils.Raster`. 45 | -------------------------------------------------------------------------------- /doc/source/mission.md: -------------------------------------------------------------------------------- 1 | (mission)= 2 | 3 | # Mission 4 | 5 | ```{epigraph} 6 | The core mission of GeoUtils is to be **easy-of-use**, **robust**, **reproducible** and **fully open**. 7 | 8 | Additionally, GeoUtils aims to be **efficient**, **scalable** and **state-of-the-art**. 9 | ``` 10 | 11 | In details, those mean: 12 | 13 | - **Ease-of-use:** all basic operations or methods only require a few lines of code to be performed; 14 | 15 | - **Robustness:** all methods are tested within our continuous integration test-suite, to enforce that they always perform as expected; 16 | 17 | - **Reproducibility:** all code is version-controlled and release-based, to ensure consistency of dependent packages and works; 18 | 19 | - **Open-source:** all code is accessible and reusable to anyone in the community, for transparency and open governance. 20 | 21 | ```{note} 22 | :class: margin 23 | Additional mission points, in particular **scalability**, are partly developed with aim of being finalized when our long-term ``v1.0`` is reached. 24 | ``` 25 | 26 | And, additionally: 27 | 28 | - **Efficiency**: all methods should be optimized at the lower-level, to function with the highest performance offered by Python packages; 29 | 30 | - **Scalability**: all methods should support both lazy processing and distributed parallelized processing, to work with high-resolution data on local machines as well as on HPCs; 31 | 32 | - **State-of-the-art**: all methods should be at the cutting edge of remote sensing science, to provide users with the most reliable and up-to-date tools. 33 | -------------------------------------------------------------------------------- /examples/analysis/array_numerics/numpy_interfacing.py: -------------------------------------------------------------------------------- 1 | """ 2 | NumPy interfacing 3 | ================= 4 | 5 | This example demonstrates NumPy interfacing with rasters on :class:`Rasters`. See :ref:`core-array-funcs` for more details. 6 | """ 7 | 8 | # %% 9 | # We open a raster. 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("exploradores_aster_dem") 15 | rast = gu.Raster(filename_rast) 16 | 17 | # %% We plot it. 18 | rast.plot(cmap="terrain") 19 | 20 | # %% 21 | # 22 | # The NumPy interface allows to use almost any NumPy function directly on the raster. 23 | 24 | import numpy as np 25 | 26 | # Get the x and y gradient as 1D arrays 27 | gradient_y, gradient_x = np.gradient(rast) 28 | # Estimate the orientation in degrees casting to 2D 29 | aspect = np.arctan2(-gradient_x, gradient_y) 30 | aspect = (aspect * 180 / np.pi) + np.pi 31 | 32 | aspect.plot(cmap="twilight", cbar_title="Aspect (degrees)") 33 | 34 | # %% 35 | # 36 | # .. important:: 37 | # For rigorous slope and aspect calculation (matching that of GDAL), **check-out our sister package** `xDEM `_. 38 | # 39 | # We use NumPy logical operations to isolate the terrain oriented South and above three thousand meters. The rasters will be logically cast to a 40 | # boolean :class:`Raster`. 41 | 42 | mask = np.logical_and.reduce((aspect > -45, aspect < 45, rast > 3000)) 43 | mask 44 | 45 | # %% 46 | # We plot the mask. 47 | 48 | mask.plot() 49 | -------------------------------------------------------------------------------- /examples/analysis/array_numerics/python_arithmetic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python arithmetic 3 | ================= 4 | 5 | This example demonstrates arithmetic operations using raster arithmetic on :class:`Rasters`. See :ref:`core-py-ops` for more details. 6 | """ 7 | 8 | # %% 9 | # We open a raster 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("everest_landsat_b4") 15 | rast = gu.Raster(filename_rast) 16 | rast 17 | 18 | # %% We plot the original raster. 19 | rast.plot(cmap="Greys_r") 20 | 21 | # %% 22 | # Performing arithmetic operations implicitly loads the data. 23 | rast = (rast + 1.0) ** 0.5 / 5 24 | rast.plot(cmap="Greys_r") 25 | 26 | # %% 27 | # 28 | # .. important:: 29 | # Arithmetic operations cast to new :class:`dtypes` automatically following NumPy coercion rules. If we had written ``(rast + 1)``, 30 | # this calculation would have conserved the original :class:`numpy.uint8` :class:`dtype` of the raster. 31 | # 32 | # Logical comparison operations will naturally cast to a boolean :class:`Raster`. 33 | 34 | mask = rast == 200 35 | mask 36 | 37 | # %% 38 | # Boolean :class:`Rasters` support python logical operators to be combined together 39 | 40 | mask = (rast >= 3) | (rast % 2 == 0) & (rast != 80) 41 | mask.plot() 42 | 43 | # %% 44 | # Finally, boolean :class:`Rasters` can be used for indexing and assigning to a :class:`Rasters` 45 | 46 | values = rast[mask] 47 | -------------------------------------------------------------------------------- /geoutils/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """ 20 | GeoUtils is a Python package for the analysis of geospatial data. 21 | """ 22 | 23 | from geoutils import examples, projtools # noqa 24 | from geoutils._config import config # noqa 25 | 26 | from geoutils.raster import Raster # noqa isort:skip 27 | from geoutils.vector import Vector # noqa isort:skip 28 | from geoutils.pointcloud import PointCloud # noqa isort:skip 29 | 30 | # To-be-deprecated 31 | from geoutils.raster import Mask # noqa isort:skip 32 | 33 | try: 34 | from geoutils._version import __version__ as __version__ # noqa 35 | except ImportError: # pragma: no cover 36 | raise ImportError( 37 | "geoutils is not properly installed. If you are " 38 | "running from the source directory, please instead " 39 | "create a new virtual environment (using conda or " 40 | "virtualenv) and then install it in-place by running: " 41 | "pip install -e ." 42 | ) 43 | -------------------------------------------------------------------------------- /.github/workflows/testpypi-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package on TestPypi 2 | 3 | name: Publish to TestPyPI 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | reason: 9 | description: 'Publishing alphas version for testing' 10 | 11 | jobs: 12 | build: 13 | name: Build distribution 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | persist-credentials: false 20 | - name: Set up Python 21 | uses: actions/setup-python@v6 22 | with: 23 | python-version: '3.12' 24 | - name: Install pypa/build 25 | run: python3 -m pip install build --user 26 | - name: Build a binary wheel and a source tarball 27 | run: python3 -m build 28 | - name: Store the distribution packages 29 | uses: actions/upload-artifact@v6 30 | with: 31 | name: python-package-distributions 32 | path: dist/ 33 | 34 | publish-to-testpypi: 35 | name: Publish Python 🐍 distribution 📦 to TestPyPI 36 | needs: 37 | - build 38 | runs-on: ubuntu-latest 39 | 40 | environment: 41 | name: testpypi 42 | url: https://test.pypi.org/p/geoutils 43 | 44 | permissions: 45 | id-token: write # IMPORTANT: mandatory for trusted publishing 46 | 47 | steps: 48 | - name: Download all the dists 49 | uses: actions/download-artifact@v7 50 | with: 51 | name: python-package-distributions 52 | path: dist/ 53 | - name: Publish distribution 📦 to TestPyPI 54 | uses: pypa/gh-action-pypi-publish@release/v1 55 | with: 56 | repository-url: https://test.pypi.org/legacy/ 57 | -------------------------------------------------------------------------------- /examples/analysis/distance_ops/proximity_metric.py: -------------------------------------------------------------------------------- 1 | """ 2 | Proximity to raster or vector 3 | ============================= 4 | 5 | This example demonstrates the calculation of proximity distances to a raster or vector using :func:`~geoutils.Raster.proximity`. 6 | """ 7 | 8 | # %% 9 | # We open an example raster, and a vector for which we select a single feature 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("everest_landsat_b4") 15 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 16 | rast = gu.Raster(filename_rast) 17 | vect = gu.Vector(filename_vect) 18 | vect = vect[vect["RGIId"] == "RGI60-15.10055"] 19 | rast = rast.crop(vect) 20 | 21 | # Plot the raster and vector 22 | rast.plot(cmap="Blues") 23 | vect.reproject(rast).plot(fc="none", ec="k", lw=2) 24 | 25 | # %% 26 | # We use the raster as a reference to match for rasterizing the proximity distances with :func:`~geoutils.Vector.proximity`. See :ref:`core-match-ref` for more details. 27 | 28 | proximity = vect.proximity(rast) 29 | proximity.plot(cmap="viridis") 30 | 31 | # %% 32 | # Proximity can also be computed to target pixels of a raster, or that of a mask 33 | 34 | # Get mask of pixels within 30 of 200 infrared 35 | import numpy as np 36 | 37 | mask_200 = np.abs(rast - 200) < 30 38 | mask_200.plot() 39 | 40 | # %% 41 | # Because a mask is :class:`bool`, no need to pass target pixels 42 | 43 | proximity_mask = mask_200.proximity() 44 | proximity_mask.plot(cmap="viridis") 45 | 46 | # %% 47 | # By default, proximity is computed using the georeference unit from a :class:`~geoutils.Raster`'s :attr:`~geoutils.Raster.res`, here **meters**. It can also 48 | # be computed in pixels. 49 | 50 | proximity_mask = mask_200.proximity(distance_unit="pixel") 51 | proximity_mask.plot(cmap="viridis") 52 | -------------------------------------------------------------------------------- /examples/io/import_export/raster_from_array.py: -------------------------------------------------------------------------------- 1 | """ 2 | Creating a raster from array 3 | ============================ 4 | 5 | This example demonstrates the creation of a raster through :func:`~geoutils.Raster.from_array`. 6 | """ 7 | 8 | import numpy as np 9 | import pyproj 10 | import rasterio as rio 11 | 12 | # %% 13 | # We create a data array as :class:`~numpy.ndarray`, a transform as :class:`affine.Affine` and a coordinate reference system (CRS) as :class:`pyproj.CRS`. 14 | import geoutils as gu 15 | 16 | # A random 3 x 3 masked array 17 | rng = np.random.default_rng(42) 18 | arr = rng.normal(size=(5, 5)) 19 | # Introduce a NaN value 20 | arr[2, 2] = np.nan 21 | # A transform with 3 x 3 pixels in a [0-1, 0-1] bound square 22 | transform = rio.transform.from_bounds(0, 0, 1, 1, 3, 3) 23 | # A CRS, here geographic (latitude/longitude) 24 | crs = pyproj.CRS.from_epsg(4326) 25 | 26 | # Create a raster 27 | rast = gu.Raster.from_array(data=arr, transform=transform, crs=crs, nodata=255) 28 | rast 29 | 30 | # %% 31 | # We can print info on the raster. 32 | rast.info() 33 | 34 | # %% 35 | # The array has been automatically cast into a :class:`~numpy.ma.MaskedArray`, to respect :class:`~geoutils.Raster.nodata` values. 36 | rast.data 37 | 38 | # %% 39 | # We could also have created directly from a :class:`~numpy.ma.MaskedArray`. 40 | 41 | # A random mask, that will mask one out of two values on average 42 | mask = rng.integers(0, 2, size=(5, 5), dtype="bool") 43 | ma = np.ma.masked_array(data=arr, mask=mask) 44 | 45 | # This time, we pass directly the masked array 46 | rast = gu.Raster.from_array(data=ma, transform=transform, crs=crs, nodata=255) 47 | rast 48 | 49 | # %% 50 | # The different functionalities of GeoUtils will respect :class:`~geoutils.Raster.nodata` values, starting with :func:`~geoutils.Raster.plot`, 51 | # which will ignore them during plotting (transparent). 52 | rast.plot(cmap="copper") 53 | -------------------------------------------------------------------------------- /doc/source/core_parsing.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | (satimg-parsing)= 14 | 15 | # Sensor metadata parsing 16 | 17 | GeoUtils functionalities for remote sensing users interested in parsing metadata from space- or airborne imagery. 18 | 19 | ## Parsing metadata at raster instantiation 20 | 21 | A {class}`~geoutils.Raster` can be instantiated while trying to parse metadata usint the ``parse_sensor_metadata`` argument. 22 | 23 | ```{code-cell} ipython3 24 | import geoutils as gu 25 | 26 | # Parse metadata from an ASTER raster 27 | filename_aster = gu.examples.get_path("exploradores_aster_dem") 28 | rast_aster = gu.Raster(filename_aster, parse_sensor_metadata=True, silent=False) 29 | ``` 30 | 31 | 32 | ```{code-cell} ipython3 33 | # Parse metadata from a Landsat 7 raster 34 | filename_landsat = gu.examples.get_path("everest_landsat_b4") 35 | rast_landsat = gu.Raster(filename_landsat, parse_sensor_metadata=True, silent=False) 36 | ``` 37 | 38 | The metadata is then stored in the {attr}`~geoutils.Raster.tags` attribute of the raster. 39 | 40 | ```{code-cell} ipython3 41 | rast_aster.tags 42 | ``` 43 | 44 | For tiled products such as SRTM, the tile naming is also retrieved, and converted to usable tile sizes and extents based on known metadata. 45 | 46 | ## Supported sensors 47 | 48 | Right now are supported: 49 | - Landsat, 50 | - Sentinel-2, 51 | - SPOT, 52 | - ASTER, 53 | - ArcticDEM and REMA, 54 | - ALOS, 55 | - SRTM, 56 | - TanDEM-X, and 57 | - NASADEM. 58 | 59 | ```{important} 60 | Sensor metadata parsing is still in development. We hope to add the ability to parse from 61 | auxiliary files in the future (such as [here](https://github.com/jlandmann/glaciersat/blob/master/glaciersat/core/imagery.py)). 62 | ``` 63 | -------------------------------------------------------------------------------- /examples/handling/raster_vector/create_mask.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mask from a vector 3 | ================== 4 | 5 | This example demonstrates the creation of a mask from a vector using :func:`geoutils.Vector.create_mask`. 6 | """ 7 | 8 | # %% 9 | # We open a raster and vector. 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("everest_landsat_b4") 15 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 16 | rast = gu.Raster(filename_rast) 17 | vect = gu.Vector(filename_vect) 18 | 19 | # %% 20 | # Let's plot the raster and vector. 21 | rast.plot(cmap="Purples") 22 | vect.plot(ref_crs=rast, fc="none", ec="k", lw=2) 23 | 24 | # %% 25 | # **First option:** using the raster as a reference to match, we create a mask for the vector in any projection and georeferenced grid. We simply have to pass 26 | # the :class:`~geoutils.Raster` as single argument to :func:`~geoutils.Vector.rasterize`. See :ref:`core-match-ref` for more details. 27 | 28 | vect_rasterized = vect.create_mask(rast) 29 | vect_rasterized.plot(ax="new") 30 | 31 | # %% 32 | # .. note:: 33 | # This is equivalent to using :func:`~geoutils.Vector.rasterize` with ``in_value=1`` and ``out_value=0`` and 34 | # will return a boolean :class:`~geoutils.Raster`. 35 | 36 | vect_rasterized 37 | 38 | # %% 39 | # **Second option:** we can pass any georeferencing parameter to :func:`~geoutils.Raster.create_mask`. Any unpassed attribute will be deduced from the 40 | # :class:`~geoutils.Vector` itself, except from the :attr:`~geoutils.Raster.shape` to rasterize that will default to 1000 x 1000. 41 | 42 | 43 | # vect_rasterized = vect.create_mask(xres=500) 44 | # vect_rasterized.plot() 45 | 46 | # %% 47 | # .. important:: 48 | # The :attr:`~geoutils.Raster.shape` or the :attr:`~geoutils.Raster.res` are the only unknown arguments to rasterize a :class:`~geoutils.Vector`, 49 | # one or the other can be passed. 50 | # 51 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Overview: making a contribution 4 | 5 | For more details, see the rest of this document. 6 | 7 | 1. Fork _GlacioHack/geoutils_ and clone your fork repository locally. 8 | 2. Set up the development environment (section below). 9 | 3. Create a branch for the new feature or bug fix. 10 | 4. Make your changes, and add or modify related tests in _tests/_. 11 | 5. Commit, making sure to run `pre-commit` separately if not installed as git hook. 12 | 6. Push to your fork. 13 | 7. Open a pull request from GitHub to discuss and eventually merge. 14 | 15 | ## Development environment 16 | 17 | GeoUtils currently supports only Python versions of 3.10 to 3.13, see `environment.yml` for detailed dependencies. 18 | 19 | ### Setup 20 | 21 | Clone the git repo and create a `mamba` environment (see how to install `mamba` in the [mamba documentation](https://mamba.readthedocs.io/en/latest/)): 22 | 23 | ```bash 24 | git clone https://github.com/GlacioHack/geoutils.git 25 | cd geoutils 26 | mamba env create -f dev-environment.yml # Add '-n custom_name' if you want. 27 | mamba activate geoutils-dev # Or any other name specified above 28 | ``` 29 | 30 | ### Tests 31 | 32 | At least one test per feature (in the associated `tests/test_*.py` file) should be included in the PR, using `pytest` (see existing tests for examples). 33 | 34 | To run the entire test suite, run `pytest` in the current directory: 35 | ```bash 36 | pytest 37 | ``` 38 | 39 | ### Formatting and linting 40 | 41 | Install and run `pre-commit` (see [pre-commit documentation](https://pre-commit.com/)), which will use `.pre-commit-config.yaml` to verify spelling errors, 42 | import sorting, type checking, formatting and linting. 43 | 44 | You can then run pre-commit manually: 45 | ```bash 46 | pre-commit run --all-files 47 | ``` 48 | 49 | Optionally, `pre-commit` can be installed as a git hook to ensure checks have to pass before committing. 50 | 51 | ## Rights 52 | 53 | The license (see LICENSE) applies to all contributions. 54 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package when a release is created 2 | # See reference: https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 3 | 4 | name: Publish to PyPI 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | workflow_dispatch: 11 | inputs: 12 | reason: 13 | description: 'Reason for manual trigger' 14 | required: true 15 | default: 'testing' 16 | 17 | jobs: 18 | build: 19 | name: Build distribution 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v6 23 | with: 24 | fetch-depth: 0 25 | persist-credentials: false 26 | - name: Set up Python 27 | uses: actions/setup-python@v6 28 | with: 29 | python-version: '3.12' 30 | - name: Install pypa/build 31 | run: python3 -m pip install build --user 32 | - name: Build a binary wheel and a source tarball 33 | run: python3 -m build 34 | - name: Store the distribution packages 35 | uses: actions/upload-artifact@v6 36 | with: 37 | name: python-package-distributions 38 | path: dist/ 39 | 40 | publish-to-pypi: 41 | name: Publish distribution to PyPI 42 | # if: startsWith(github.ref, 'refs/tags/') # Only publish to PyPI on tag pushes, required depending on trigger at the top 43 | needs: 44 | - build 45 | runs-on: ubuntu-latest 46 | environment: 47 | name: pypi 48 | url: https://pypi.org/p/geoutils # Replace with your PyPI project name 49 | permissions: 50 | id-token: write # IMPORTANT: mandatory for trusted publishing 51 | 52 | steps: 53 | - name: Download all the dists 54 | uses: actions/download-artifact@v7 55 | with: 56 | name: python-package-distributions 57 | path: dist/ 58 | - name: Publish distribution to PyPI 59 | uses: pypa/gh-action-pypi-publish@release/v1 60 | -------------------------------------------------------------------------------- /examples/handling/georeferencing/crop_raster.py: -------------------------------------------------------------------------------- 1 | """ 2 | Crop a raster 3 | ============= 4 | 5 | This example demonstrates the cropping of a raster using :func:`geoutils.Raster.crop`. 6 | """ 7 | 8 | # %% 9 | # We open a raster and vector, and subset the latter. 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("everest_landsat_b4") 15 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 16 | rast = gu.Raster(filename_rast) 17 | vect = gu.Vector(filename_vect) 18 | vect = vect[vect["RGIId"] == "RGI60-15.10055"] 19 | 20 | # %% 21 | # The first raster has larger extent and higher resolution than the vector. 22 | rast.info() 23 | print(vect.bounds) 24 | 25 | # %% 26 | # Let's plot the raster and vector. 27 | rast.plot(cmap="Purples") 28 | vect.plot(ref_crs=rast, fc="none", ec="k", lw=2) 29 | 30 | # %% 31 | # **First option:** using the vector as a reference to match, we reproject the raster. We simply have to pass the :class:`~geoutils.Vector` 32 | # as single argument to :func:`~geoutils.Raster.crop`. See :ref:`core-match-ref` for more details. 33 | 34 | rast = rast.crop(vect) 35 | 36 | # %% 37 | # Now the bounds should be the same as that of the vector (within the size of a pixel as the grid was not warped). 38 | # 39 | # .. note:: 40 | # By default, :func:`~geoutils.Raster.crop` is done in-place, replacing ``rast``. This behaviour can be modified by passing ``inplace=False``. 41 | # 42 | 43 | rast.plot(ax="new", cmap="Purples") 44 | vect.plot(ref_crs=rast, fc="none", ec="k", lw=2) 45 | 46 | # %% 47 | # **Second option:** we can pass other ``crop_geom`` argument to :func:`~geoutils.Raster.crop`, including another :class:`~geoutils.Raster` or a 48 | # simple :class:`tuple` of bounds. For instance, we can re-crop the raster to be smaller than the vector. 49 | 50 | rast = rast.crop((rast.bounds.left + 1000, rast.bounds.bottom, rast.bounds.right, rast.bounds.top - 500)) 51 | 52 | rast.plot(ax="new", cmap="Purples") 53 | vect.plot(ref_crs=rast, fc="none", ec="k", lw=2) 54 | -------------------------------------------------------------------------------- /examples/handling/raster_point/interpolation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interpolate raster at points 3 | ============================ 4 | 5 | This example demonstrates the 2D interpolation of raster values to points using :func:`~geoutils.Raster.interp_points`. 6 | """ 7 | 8 | # %% 9 | # We open an example raster, a digital elevation model in South America. 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("exploradores_aster_dem") 15 | rast = gu.Raster(filename_rast) 16 | rast = rast.crop([rast.bounds.left, rast.bounds.bottom, rast.bounds.left + 2000, rast.bounds.bottom + 2000]) 17 | 18 | # Plot the raster 19 | rast.plot(cmap="terrain") 20 | 21 | # %% 22 | # We generate a random subsample of 100 coordinates to interpolate. 23 | 24 | import numpy as np 25 | 26 | rng = np.random.default_rng(42) 27 | x_coords = rng.uniform(rast.bounds.left + 50, rast.bounds.right - 50, 50) 28 | y_coords = rng.uniform(rast.bounds.bottom + 50, rast.bounds.top - 50, 50) 29 | 30 | pc = rast.interp_points(points=(x_coords, y_coords)) 31 | 32 | # %% 33 | # We plot the resulting point cloud 34 | pc.plot(ax="new", cmap="terrain", marker="x", cbar_title="Elevation (m)") 35 | 36 | # %% 37 | # .. important:: 38 | # The interpretation of where raster values are located can differ. The parameter ``shift_area_or_point`` (off by default) can be turned on to ensure 39 | # that the pixel interpretation of your dataset is correct. 40 | 41 | # %% 42 | # Let's look and redefine our pixel interpretation into ``"Point"``. This will shift interpolation by half a pixel. 43 | 44 | rast.area_or_point 45 | rast.area_or_point = "Point" 46 | 47 | # %% 48 | # We can interpolate again by shifting according to our interpretation, and changing the resampling algorithm (default to "linear"). 49 | 50 | pc_shifted = rast.interp_points(points=(x_coords, y_coords), shift_area_or_point=True, method="quintic") 51 | np.nanmean(pc - pc_shifted) 52 | 53 | # %% 54 | # The mean difference in interpolated values is quite significant, with a 2-meter bias! 55 | -------------------------------------------------------------------------------- /examples/io/open_save/read_raster.py: -------------------------------------------------------------------------------- 1 | """ 2 | Open/save a raster 3 | ================== 4 | 5 | This example demonstrates the instantiation of a raster through :class:`~geoutils.Raster` and saving with :func:`~geoutils.Raster.to_file`. 6 | """ 7 | 8 | # %% 9 | # We open an example raster. The data is, by default, unloaded. 10 | import geoutils as gu 11 | 12 | filename_rast = gu.examples.get_path("everest_landsat_b4") 13 | rast = gu.Raster(filename_rast) 14 | rast 15 | 16 | # %% 17 | # A raster is composed of four main attributes: a :class:`~geoutils.Raster.data` array, an affine :class:`~geoutils.Raster.transform`, 18 | # a coordinate reference system :class:`~geoutils.Raster.crs` and a :class:`~geoutils.Raster.nodata` value. 19 | # All other attributes are derivatives of those or the file on disk, and can be found in the :ref:`dedicated section of the API`. See also :ref:`raster-class`. 20 | 21 | # %% 22 | # 23 | # .. note:: 24 | # A raster can also be instantiated with a :class:`rasterio.io.DatasetReader` or a :class:`rasterio.io.MemoryFile`, see :ref:`sphx_glr_io_examples_import_export_import_raster.py`. 25 | # 26 | # We can print more info on the raster. 27 | rast.info() 28 | 29 | # %% 30 | # The data will be loaded explicitly by any function requiring its :attr:`~geoutils.Raster.data`, such as :func:`~geoutils.Raster.show`. 31 | rast.plot(cmap="Greys_r") 32 | 33 | # %% 34 | # Opening can be performed with several parameters, for instance choosing a single band with ``index`` and re-sampling with ``downsample``, to subset a 3-band 35 | # raster to its second band, and using 1 pixel out of 4. 36 | rast = gu.Raster(gu.examples.get_path("everest_landsat_rgb"), bands=2, downsample=4) 37 | rast 38 | 39 | # %% 40 | # The data is not loaded by default, even if when specifying a band or re-sampling. 41 | # We can load it explicitly by calling :func:`~geoutils.Raster.load` (could have also passed ``load_data=True`` to :class:`~geoutils.Raster`). 42 | rast.load() 43 | rast 44 | 45 | # %% 46 | # Finally, a raster is saved using :func:`~geoutils.Raster.to_file`: 47 | rast.to_file("myraster.tif") 48 | -------------------------------------------------------------------------------- /.github/scripts/generate_yml_env_fixed_py.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | 5 | import yaml # type: ignore 6 | 7 | 8 | def environment_yml_nopy(fn_env: str, py_version: str, add_deps: list[str] = None) -> None: 9 | """ 10 | Generate temporary environment-py3.XX.yml files forcing python versions for setup of continuous integration. 11 | 12 | :param fn_env: Filename path to environment.yml 13 | :param py_version: Python version to force. 14 | :param add_deps: Additional dependencies to solve for directly (for instance graphviz fails with mamba update). 15 | """ 16 | 17 | # Load the yml as dictionary 18 | yaml_env = yaml.safe_load(open(fn_env)) 19 | conda_dep_env = list(yaml_env["dependencies"]) 20 | 21 | # Force python version 22 | conda_dep_env_forced_py = ["python=" + py_version if "python" in dep else dep for dep in conda_dep_env] 23 | 24 | # Optionally, add other dependencies 25 | if add_deps is not None: 26 | conda_dep_env_forced_py.extend(add_deps.split(",")) 27 | 28 | # Copy back to new yaml dict 29 | yaml_out = yaml_env.copy() 30 | yaml_out["dependencies"] = conda_dep_env_forced_py 31 | 32 | with open("environment-ci-py" + py_version + ".yml", "w") as outfile: 33 | yaml.dump(yaml_out, outfile, default_flow_style=False) 34 | 35 | 36 | if __name__ == "__main__": 37 | parser = argparse.ArgumentParser(description="Generate environment files for CI with fixed python versions.") 38 | parser.add_argument("fn_env", metavar="fn_env", type=str, help="Path to the generic environment file.") 39 | parser.add_argument( 40 | "--pyv", 41 | dest="py_version", 42 | default="3.9", 43 | type=str, 44 | help="List of Python versions to force.", 45 | ) 46 | parser.add_argument( 47 | "--add", 48 | dest="add_deps", 49 | default=None, 50 | type=str, 51 | help="List of dependencies to add.", 52 | ) 53 | args = parser.parse_args() 54 | environment_yml_nopy(fn_env=args.fn_env, py_version=args.py_version, add_deps=args.add_deps) 55 | -------------------------------------------------------------------------------- /doc/source/code/about_geoutils_sidebyside_raster_rasterio.py: -------------------------------------------------------------------------------- 1 | #### 2 | # Part not shown in the doc, to get data files ready 3 | import geoutils 4 | 5 | landsat_b4_path = geoutils.examples.get_path("everest_landsat_b4") 6 | landsat_b4_crop_path = geoutils.examples.get_path("everest_landsat_b4_cropped") 7 | geoutils.Raster(landsat_b4_path).to_file("myraster1.tif") 8 | geoutils.Raster(landsat_b4_crop_path).to_file("myraster2.tif") 9 | #### 10 | 11 | import numpy as np 12 | import rasterio as rio 13 | 14 | # Opening of two rasters 15 | rast1 = rio.io.DatasetReader("myraster1.tif") 16 | rast2 = rio.io.DatasetReader("myraster2.tif") 17 | 18 | # Equivalent of a match-reference reprojection 19 | # (returns an array, not a raster-type object) 20 | arr1_reproj, _ = rio.warp.reproject( 21 | source=rast1.read(), 22 | destination=np.ones(rast2.shape), 23 | src_transform=rast1.transform, 24 | src_crs=rast1.crs, 25 | src_nodata=rast1.nodata, 26 | dst_transform=rast2.transform, 27 | dst_crs=rast2.crs, 28 | dst_nodata=rast2.nodata, 29 | ) 30 | 31 | # Equivalent of array interfacing 32 | # (ensuring nodata and dtypes are rightly 33 | # propagated through masked arrays) 34 | ma1_reproj = np.ma.MaskedArray(data=arr1_reproj, mask=(arr1_reproj == rast2.nodata)) 35 | ma2 = rast2.read(masked=True) 36 | ma_result = (1 + ma2) / (ma1_reproj) 37 | 38 | 39 | # Equivalent of saving 40 | # (requires to define a logical 41 | # nodata for the data type) 42 | def custom_func(dtype, nodata1, nodata2): 43 | return -9999 44 | 45 | 46 | out_nodata = custom_func(dtype=ma_result.dtype, nodata1=rast1.nodata, nodata2=rast2.nodata) 47 | with rio.open( 48 | "myresult.tif", 49 | mode="w", 50 | height=rast2.height, 51 | width=rast2.width, 52 | count=rast1.count, 53 | dtype=ma_result.dtype, 54 | crs=rast2.crs, 55 | transform=rast2.transform, 56 | nodata=rast2.nodata, 57 | ) as dst: 58 | dst.write(ma_result.filled(out_nodata)) 59 | 60 | #### 61 | # Part not shown in the docs, to clean up 62 | import os 63 | 64 | for file in ["myraster1.tif", "myraster2.tif", "myresult.tif"]: 65 | os.remove(file) 66 | #### 67 | -------------------------------------------------------------------------------- /doc/source/core_inheritance.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | (core-inheritance)= 14 | # Inheritance to DEMs and beyond 15 | 16 | Inheritance is practical to naturally pass down parent methods and attributes to child classes. 17 | 18 | Many subtypes of {class}`Rasters` geospatial data exist that require additional attributes and methods, yet might benefit from methods 19 | implemented in GeoUtils. 20 | 21 | ## Overview of {class}`~geoutils.Raster` inheritance 22 | 23 | Current {class}`~geoutils.Raster` inheritance extends into other packages, such as [xDEM](https://xdem.readthedocs.io/) 24 | for analyzing digital elevation models. 25 | 26 | ```{eval-rst} 27 | .. inheritance-diagram:: geoutils.raster.raster 28 | :top-classes: geoutils.raster.raster.Raster 29 | ``` 30 | 31 | ```{note} 32 | The {class}`~xdem.DEM` class re-implements all methods of [gdalDEM](https://gdal.org/programs/gdaldem.html) (and more) to derive topographic attributes 33 | (hillshade, slope, aspect, etc), coded directly in Python for scalability and tested to yield the exact same results. 34 | Among others, it also adds a {attr}`~xdem.DEM.vcrs` property to consistently manage vertical referencing (ellipsoid, geoids). 35 | 36 | If you are DEM-enthusiastic, **[check-out our sister package xDEM](https://xdem.readthedocs.io/) for digital elevation models.** 37 | ``` 38 | 39 | ## And beyond 40 | 41 | Many types of geospatial data can be viewed as a subclass of {class}`Rasters`, which have more attributes and require their own methods: 42 | **spectral images**, **velocity fields**, **phase difference maps**, etc... 43 | 44 | If you are interested to build your own subclass of {class}`~geoutils.Raster`, you can take example of the structure of {class}`xdem.DEM`. 45 | Then, just add any of your own attributes and methods, and overload parent methods if necessary! Don't hesitate to reach out on our 46 | GitHub if you have a subclassing project. 47 | -------------------------------------------------------------------------------- /geoutils/vector/geotransformations.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Functionalities for geotransformations of vectors.""" 20 | 21 | from __future__ import annotations 22 | 23 | import geopandas as gpd 24 | import rasterio as rio 25 | from rasterio.crs import CRS 26 | 27 | import geoutils as gu 28 | 29 | 30 | def _reproject( 31 | gdf: gpd.GeoDataFrame, 32 | ref: gu.Raster | rio.io.DatasetReader | gu.Vector | gpd.GeoDataFrame | None = None, 33 | crs: CRS | str | int | None = None, 34 | ) -> gpd.GeoDataFrame: 35 | """Reproject a vector. See Vector.reproject() for more details.""" 36 | 37 | # Check that either ref or crs is provided 38 | if (ref is not None and crs is not None) or (ref is None and crs is None): 39 | raise ValueError("Either of `ref` or `crs` must be set. Not both.") 40 | 41 | # Case a raster or vector is provided as reference 42 | if ref is not None: 43 | # Check that ref type is either str, Raster or rasterio data set 44 | if isinstance(ref, (gu.Raster, gu.Vector, rio.io.DatasetReader, gpd.GeoDataFrame)): 45 | ds_ref = ref 46 | else: 47 | raise TypeError("Type of ref must be a raster or vector.") 48 | 49 | # Read reprojecting params from ref raster 50 | crs = ds_ref.crs 51 | else: 52 | # Determine user-input target CRS 53 | crs = CRS.from_user_input(crs) 54 | 55 | new_ds = gdf.to_crs(crs=crs) 56 | 57 | return new_ds 58 | -------------------------------------------------------------------------------- /examples/handling/georeferencing/crop_vector.py: -------------------------------------------------------------------------------- 1 | """ 2 | Crop a vector 3 | ============= 4 | 5 | This example demonstrates the cropping of a vector using :func:`geoutils.Vector.crop`. 6 | """ 7 | 8 | # %% 9 | # We open a raster and vector. 10 | 11 | # sphinx_gallery_thumbnail_number = 3 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("everest_landsat_b4_cropped") 15 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 16 | rast = gu.Raster(filename_rast) 17 | vect = gu.Vector(filename_vect) 18 | 19 | # %% 20 | # Let's plot the raster and vector. The raster has smaller extent than the vector. 21 | rast.plot(cmap="Greys_r", alpha=0.7) 22 | vect.plot(ref_crs=rast, fc="none", ec="tab:purple", lw=3) 23 | 24 | # %% 25 | # **First option:** using the raster as a reference to match, we crop the vector. We simply have to pass the :class:`~geoutils.Raster` as single argument to 26 | # :func:`~geoutils.Vector.crop`. See :ref:`core-match-ref` for more details. 27 | 28 | vect = vect.crop(rast) 29 | 30 | # %% 31 | # .. note:: 32 | # By default, :func:`~geoutils.Vector.crop` is done in-place, replacing ``vect``. This behaviour can be modified by passing ``inplace=False``. 33 | # 34 | 35 | rast.plot(ax="new", cmap="Greys_r", alpha=0.7) 36 | vect.plot(ref_crs=rast, fc="none", ec="tab:purple", lw=3) 37 | 38 | # %% 39 | # The :func:`~geoutils.Vector.crop` keeps all features with geometries intersecting the extent to crop to. We can also force a clipping of the geometries 40 | # within the bounds using ``clip=True``. 41 | 42 | vect = vect.crop(rast, clip=True) 43 | rast.plot(ax="new", cmap="Greys_r", alpha=0.7) 44 | vect.plot(ref_crs=rast, fc="none", ec="tab:purple", lw=3) 45 | 46 | # %% 47 | # **Second option:** we can pass other ``crop_geom`` argument to :func:`~geoutils.Vector.crop`, including another :class:`~geoutils.Vector` or a 48 | # simple :class:`tuple` of bounds. 49 | 50 | bounds = rast.get_bounds_projected(out_crs=vect.crs) 51 | vect = vect.crop(crop_geom=(bounds.left + 0.5 * (bounds.right - bounds.left), bounds.bottom, bounds.right, bounds.top)) 52 | 53 | rast.plot(ax="new", cmap="Greys_r", alpha=0.7) 54 | vect.plot(ref_crs=rast, fc="none", ec="tab:purple", lw=3) 55 | -------------------------------------------------------------------------------- /tests/test_raster/test_distributing_computing/test_cluster.py: -------------------------------------------------------------------------------- 1 | """ Functions to test the clusters.""" 2 | 3 | import time 4 | 5 | import pytest 6 | 7 | from geoutils.raster.distributed_computing.cluster import ( 8 | BasicCluster, 9 | ClusterGenerator, 10 | MpCluster, 11 | ) 12 | 13 | 14 | # Sample function for testing 15 | def sample_function(x: float, y: float) -> float: 16 | return x + y 17 | 18 | 19 | # Function to simulate a long-running task 20 | def long_running_task(x: float) -> float: 21 | time.sleep(0.01) 22 | return x * 2 23 | 24 | 25 | class TestClusterGenerator: 26 | def test_basic_cluster(self) -> None: 27 | # Test that tasks are run synchronously in BasicCluster 28 | cluster = ClusterGenerator(name="basic") 29 | assert isinstance(cluster, BasicCluster) 30 | 31 | result = cluster.launch_task(sample_function, args=[2, 3]) 32 | assert result == 5 33 | 34 | def test_mp_cluster_task(self) -> None: 35 | # Test that tasks are launched asynchronously in MpCluster 36 | cluster = ClusterGenerator("multiprocessing", nb_workers=2) 37 | assert isinstance(cluster, MpCluster) 38 | 39 | future = cluster.launch_task(sample_function, args=[2, 3]) 40 | result = cluster.get_res(future) 41 | assert result == 5 42 | 43 | def test_mp_cluster_parallelism(self) -> None: 44 | # Test that multiple tasks are run in parallel 45 | cluster = ClusterGenerator("multiprocessing", nb_workers=2) 46 | assert isinstance(cluster, MpCluster) 47 | 48 | futures = [cluster.launch_task(long_running_task, args=[i]) for i in range(4)] 49 | results = [cluster.get_res(f) for f in futures] 50 | assert results == [0, 2, 4, 6] 51 | 52 | def test_mp_cluster_termination(self) -> None: 53 | # Test that the pool terminates correctly after closing 54 | cluster = ClusterGenerator("multiprocessing", nb_workers=2) 55 | assert isinstance(cluster, MpCluster) 56 | 57 | # Close the cluster 58 | cluster.close() 59 | 60 | # Expect an error when trying to launch a task after closing 61 | with pytest.raises(ValueError): 62 | cluster.launch_task(sample_function, args=[2, 3]) 63 | -------------------------------------------------------------------------------- /examples/handling/raster_point/reduction.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reduce raster around points 3 | =========================== 4 | 5 | This example demonstrates the reduction of windowed raster values around a point using :func:`~geoutils.Raster.reduce_points`. 6 | """ 7 | 8 | # %% 9 | # We open an example raster, a digital elevation model in South America. 10 | 11 | # sphinx_gallery_thumbnail_number = 3 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("exploradores_aster_dem") 15 | rast = gu.Raster(filename_rast) 16 | rast = rast.crop([rast.bounds.left, rast.bounds.bottom, rast.bounds.left + 2000, rast.bounds.bottom + 2000]) 17 | 18 | # Plot the raster 19 | rast.plot(cmap="terrain") 20 | 21 | # %% 22 | # We generate a random subsample of 100 coordinates to extract. 23 | 24 | import geopandas as gpd 25 | import numpy as np 26 | 27 | # Replace by Raster function once done 28 | rng = np.random.default_rng(42) 29 | x_coords = rng.uniform(rast.bounds.left + 50, rast.bounds.right - 50, 50) 30 | y_coords = rng.uniform(rast.bounds.bottom + 50, rast.bounds.top - 50, 50) 31 | 32 | pc = rast.reduce_points((x_coords, y_coords)) 33 | 34 | # %% 35 | # We plot the resulting point cloud 36 | pc.plot(ax="new", cmap="terrain", cbar_title="Elevation (m)") 37 | 38 | # %% 39 | # By default, :func:`~geoutils.Raster.reduce_points` extracts the closest pixel value. But it can also be passed a window size and reductor function to 40 | # extract an average value or other statistic based on neighbouring pixels. 41 | 42 | pc_reduced = rast.reduce_points((x_coords, y_coords), window=5, reducer_function=np.nanmedian) 43 | 44 | np.nanmean(pc - pc_reduced) 45 | 46 | # %% 47 | # The mean difference in extracted values is quite significant at 0.3 meters! 48 | # We can visualize how the sampling took place in the windows. 49 | 50 | # Replace by Vector function once done 51 | coords = rast.coords(grid=True) 52 | x_closest = rast.copy(new_array=coords[0]).reduce_points((x_coords, y_coords), as_array=True).squeeze() 53 | y_closest = rast.copy(new_array=coords[1]).reduce_points((x_coords, y_coords), as_array=True).squeeze() 54 | from shapely.geometry import box 55 | 56 | geometry = [ 57 | box(x - 2 * rast.res[0], y - 2 * rast.res[1], x + 2 * rast.res[0], y + 2 * rast.res[1]) 58 | for x, y in zip(x_closest, y_closest) 59 | ] 60 | ds = gpd.GeoDataFrame(geometry=geometry, crs=rast.crs) 61 | ds["vals"] = pc_reduced.data 62 | ds.plot(column="vals", cmap="terrain", legend=True, vmin=np.nanmin(rast), vmax=np.nanmax(rast)) 63 | -------------------------------------------------------------------------------- /tests/test_doc.py: -------------------------------------------------------------------------------- 1 | """Functions to test the documentation.""" 2 | 3 | import os 4 | import platform 5 | import warnings 6 | 7 | 8 | class TestDocs: 9 | docs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../", "doc/") 10 | n_threads = os.getenv("N_CPUS") 11 | 12 | def test_example_code(self) -> None: 13 | """Try running each python script in the doc/source/code\ 14 | directory and check that it doesn't raise an error.""" 15 | current_dir = os.getcwd() 16 | os.chdir(os.path.join(self.docs_dir, "source")) 17 | 18 | def run_code(filename: str) -> None: 19 | """Run a python script in one thread.""" 20 | with open(filename) as infile: 21 | # Run everything except plt.show() calls. 22 | with warnings.catch_warnings(): 23 | # When running the code asynchronously, matplotlib complains a bit 24 | ignored_warnings = [ 25 | "Starting a Matplotlib GUI outside of the main thread", 26 | ".*fetching the attribute.*Polygon.*", 27 | ] 28 | # This is a GeoPandas issue 29 | warnings.simplefilter("error") 30 | 31 | for warning_text in ignored_warnings: 32 | warnings.filterwarnings("ignore", warning_text) 33 | try: 34 | exec(infile.read().replace("plt.show()", "plt.close()")) 35 | except Exception as exception: 36 | if isinstance(exception, DeprecationWarning): 37 | print(exception) 38 | else: 39 | raise RuntimeError(f"Failed on {filename}") from exception 40 | 41 | filenames = [os.path.join("code", filename) for filename in os.listdir("code/") if filename.endswith(".py")] 42 | 43 | # Some of the doc scripts in code/ fails on Windows due to permission errors 44 | if (platform.system() == "Linux") or (platform.system() == "Darwin"): 45 | 46 | for filename in filenames: 47 | run_code(filename) 48 | """ 49 | with concurrent.futures.ThreadPoolExecutor( 50 | max_workers=int(self.n_threads) if self.n_threads is not None else None 51 | ) as executor: 52 | list(executor.map(run_code, filenames)) 53 | """ 54 | 55 | os.chdir(current_dir) 56 | -------------------------------------------------------------------------------- /examples/handling/raster_vector/rasterize.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rasterize a vector 3 | ================== 4 | 5 | This example demonstrates the rasterizing of a vector using :func:`geoutils.Vector.rasterize`. 6 | """ 7 | 8 | # %% 9 | # We open a raster and vector. 10 | 11 | # sphinx_gallery_thumbnail_number = 2 12 | import geoutils as gu 13 | 14 | filename_rast = gu.examples.get_path("everest_landsat_b4") 15 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 16 | rast = gu.Raster(filename_rast) 17 | vect = gu.Vector(filename_vect) 18 | 19 | # %% 20 | # Let's plot the raster and vector. 21 | rast.plot(cmap="Purples") 22 | vect.plot(ref_crs=rast, fc="none", ec="k", lw=2) 23 | 24 | # %% 25 | # **First option:** using the raster as a reference to match, we rasterize the vector in any projection and georeferenced grid. We simply have to pass the 26 | # :class:`~geoutils.Raster` as single argument to :func:`~geoutils.Vector.rasterize`. See :ref:`core-match-ref` for more details. 27 | 28 | vect_rasterized = vect.rasterize(rast) 29 | vect_rasterized.plot(ax="new", cmap="viridis") 30 | 31 | # %% 32 | # By default, :func:`~geoutils.Vector.rasterize` will burn the index of the :class:`~geoutils.Vector`'s features in their geometry. We can specify the ``in_value`` to burn a 33 | # single value, or any iterable with the same length as there are features in the :class:`~geoutils.Vector`. An ``out_value`` can be passed to burn 34 | # outside the geometries. 35 | # 36 | 37 | vect_rasterized = vect.rasterize(rast, in_value=1) 38 | vect_rasterized.plot(ax="new") 39 | 40 | # %% 41 | # 42 | # .. note:: 43 | # If the rasterized ``in_value`` is fixed to 1 and ``out_value`` to 0 (default), then :func:`~geoutils.Vector.rasterize` is creating a boolean mask. 44 | # This is equivalent to using :func:`~geoutils.Vector.create_mask`, and will return a boolean :class:`~geoutils.Raster`. 45 | 46 | vect_rasterized 47 | 48 | # %% 49 | # **Second option:** we can pass any georeferencing parameter to :func:`~geoutils.Raster.rasterize`. Any unpassed attribute will be deduced from the 50 | # :class:`~geoutils.Vector` itself, except from the :attr:`~geoutils.Raster.shape` to rasterize that will default to 1000 x 1000. 51 | 52 | 53 | # vect_rasterized = vect.rasterize(xres=500) 54 | # vect_rasterized.plot() 55 | 56 | # %% 57 | # .. important:: 58 | # The :attr:`~geoutils.Raster.shape` or the :attr:`~geoutils.Raster.res` are the only unknown arguments to rasterize a :class:`~geoutils.Vector`, 59 | # one or the other can be passed. 60 | # 61 | -------------------------------------------------------------------------------- /examples/handling/georeferencing/reproj_raster.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reproject a raster 3 | ================== 4 | 5 | This example demonstrates the reprojection of a raster using :func:`geoutils.Raster.reproject`. 6 | """ 7 | 8 | # %% 9 | # We open two example rasters. 10 | 11 | import geoutils as gu 12 | 13 | filename_rast1 = gu.examples.get_path("everest_landsat_b4") 14 | filename_rast2 = gu.examples.get_path("everest_landsat_b4_cropped") 15 | rast1 = gu.Raster(filename_rast1) 16 | rast2 = gu.Raster(filename_rast2) 17 | 18 | # %% 19 | # The first raster has larger extent and higher resolution than the second one. 20 | rast1.info() 21 | rast2.info() 22 | 23 | # %% 24 | # Let's plot the first raster, with the warped extent of the second one. 25 | 26 | rast1.plot(cmap="Blues") 27 | vect_bounds_rast2 = gu.Vector.from_bounds_projected(rast2) 28 | vect_bounds_rast2.plot(fc="none", ec="r", lw=2) 29 | 30 | # %% 31 | # **First option:** using the second raster as a reference to match, we reproject the first one. We simply have to pass the second :class:`~geoutils.Raster` 32 | # as single argument to :func:`~geoutils.Raster.reproject`. See :ref:`core-match-ref` for more details. 33 | # 34 | # By default, a "bilinear" resampling algorithm is used. Any string or :class:`~rasterio.enums.Resampling` can be passed. 35 | 36 | rast1_warped = rast1.reproject(rast2) 37 | rast1_warped 38 | 39 | # %% 40 | # .. note:: 41 | # Because no :attr:`geoutils.Raster.nodata` value is defined in the original image, the default value ``255`` for :class:`numpy.uint8` is used. This 42 | # value is detected as already existing in the original raster, however, which raises a ``UserWarning``. If your :attr:`geoutils.Raster.nodata` is not defined, 43 | # use :func:`geoutils.Raster.set_nodata`. 44 | # 45 | # Now the shape and georeferencing should be the same as that of the second raster, shown above. 46 | 47 | rast1_warped.info() 48 | 49 | # %% 50 | # We can plot the two rasters next to one another 51 | 52 | rast1_warped.plot(ax="new", cmap="Reds") 53 | rast2.plot(ax="new", cmap="Blues") 54 | 55 | # %% 56 | # **Second option:** we can pass any georeferencing argument to :func:`~geoutils.Raster.reproject`, such as ``dst_size`` and ``dst_crs``, and will only 57 | # deduce other parameters from the raster from which it is called (for ``dst_crs``, an EPSG code can be passed directly as :class:`int`). 58 | 59 | # Ensure the right nodata value is set 60 | rast2.set_nodata(0) 61 | # Pass the desired georeferencing parameters 62 | rast2_warped = rast2.reproject(grid_size=(100, 100), crs=32645) 63 | rast2_warped.info() 64 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | author = GeoUtils developers 3 | name = geoutils 4 | version = 0.2.1 5 | description = Analysis and handling of georeferenced rasters and vectors 6 | keywords = raster, vector, geospatial, gis, xarray 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | license = Apache-2.0 10 | license_files = LICENSE 11 | platform = any 12 | classifiers = 13 | Development Status :: 4 - Beta 14 | Intended Audience :: Developers 15 | Intended Audience :: Science/Research 16 | Natural Language :: English 17 | Operating System :: OS Independent 18 | Topic :: Scientific/Engineering :: GIS 19 | Topic :: Scientific/Engineering :: Image Processing 20 | Topic :: Scientific/Engineering :: Information Analysis 21 | Programming Language :: Python 22 | Programming Language :: Python :: 3.10 23 | Programming Language :: Python :: 3.11 24 | Programming Language :: Python :: 3.12 25 | Programming Language :: Python :: 3.13 26 | Programming Language :: Python :: 3 27 | Topic :: Software Development :: Libraries :: Python Modules 28 | Typing :: Typed 29 | url = https://github.com/GlacioHack/geoutils 30 | download_url = https://pypi.org/project/geoutils/ 31 | 32 | [options] 33 | packages = find: 34 | scripts = bin/geoviewer.py 35 | zip_safe = False # https://mypy.readthedocs.io/en/stable/installed_packages.html 36 | include_package_data = True 37 | python_requires = >=3.10,<3.14 38 | # Avoid pinning dependencies in requirements.txt (which we don't do anyways, and we rely mostly on Conda) 39 | # (https://caremad.io/posts/2013/07/setup-vs-requirement/, https://github.com/pypa/setuptools/issues/1951) 40 | install_requires = file: requirements.txt 41 | 42 | [options.package_data] 43 | geoutils = 44 | config.ini 45 | py.typed 46 | 47 | [options.packages.find] 48 | include = 49 | geoutils 50 | geoutils.* 51 | 52 | [options.extras_require] 53 | opt = 54 | laspy[lazrs] 55 | scikit-image 56 | test = 57 | gdal 58 | pytest>=7,<8 59 | pytest-xdist 60 | pytest-lazy-fixtures 61 | pytest-instafail 62 | pytest-socket 63 | pytest-cov 64 | coveralls 65 | pyyaml 66 | flake8 67 | netcdf4 68 | pre-commit 69 | doc = 70 | sphinx 71 | sphinx-book-theme 72 | sphinx-gallery 73 | sphinx-design 74 | sphinx-autodoc-typehints 75 | sphinxcontrib-programoutput 76 | sphinx-argparse 77 | autovizwidget 78 | graphviz 79 | myst-nb 80 | numpydoc 81 | typing-extensions 82 | dev = 83 | %(opt)s 84 | %(test)s 85 | %(doc)s 86 | psutil 87 | plotly 88 | all = 89 | %(dev)s 90 | -------------------------------------------------------------------------------- /geoutils/_typing.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Typing aliases for internal use.""" 20 | 21 | from __future__ import annotations 22 | 23 | import sys 24 | from typing import Any, List, Tuple, Union 25 | 26 | import numpy as np 27 | 28 | # Only for Python >= 3.9 29 | if sys.version_info.minor >= 9: 30 | 31 | from numpy.typing import ( # this syntax works starting on Python 3.9 32 | ArrayLike, 33 | DTypeLike, 34 | NDArray, 35 | ) 36 | 37 | # Mypy has issues with the builtin Number type (https://github.com/python/mypy/issues/3186) 38 | Number = Union[int, float, np.integer[Any], np.floating[Any]] 39 | 40 | # Simply define here if they exist 41 | DTypeLike = DTypeLike 42 | ArrayLike = ArrayLike 43 | 44 | # Use NDArray wrapper to easily define numerical (float or int) N-D array types, and boolean N-D array types 45 | NDArrayNum = NDArray[Union[np.floating[Any], np.integer[Any]]] 46 | NDArrayBool = NDArray[np.bool_] 47 | # Define numerical (float or int) masked N-D array type 48 | MArrayNum = np.ma.masked_array[Any, np.dtype[Union[np.floating[Any], np.integer[Any]]]] 49 | MArrayBool = np.ma.masked_array[Any, np.dtype[np.bool_]] 50 | 51 | # For backward compatibility before Python 3.9 52 | else: 53 | 54 | # Mypy has issues with the builtin Number type (https://github.com/python/mypy/issues/3186) 55 | Number = Union[int, float, np.integer, np.floating] # type: ignore 56 | 57 | # Make an array-like type (since the array-like numpy type only exists in numpy>=1.20) 58 | DTypeLike = Union[str, type, np.dtype] # type: ignore 59 | ArrayLike = Union[np.ndarray, np.ma.masked_array, List[Any], Tuple[Any]] # type: ignore 60 | 61 | # Define generic types for NumPy array and masked-array (behaves as "Any" before 3.9 and plugin) 62 | NDArrayNum = np.ndarray # type: ignore 63 | NDArrayBool = np.ndarray # type: ignore 64 | MArrayNum = np.ma.masked_array # type: ignore 65 | MArrayBool = np.ma.masked_array # type: ignore 66 | -------------------------------------------------------------------------------- /doc/source/history.md: -------------------------------------------------------------------------------- 1 | (history)= 2 | 3 | # History 4 | 5 | More information on how the package was created, who are the people behind it, and its mission. 6 | 7 | ## Creation 8 | 9 | GeoUtils was created during the [GlacioHack](https://github.com/GlacioHack) hackaton event, that took place online on November 8, 2020 and was initiated by 10 | Amaury Dehecq2. 11 | 12 | ```{margin} 13 | 2More on our GlacioHack founder at [adehecq.github.io](https://adehecq.github.io/)! 14 | ``` 15 | 16 | GeoUtils is inspired by previous efforts that were built directly on top of GDAL and OGR, namely: 17 | 18 | - the older, homonymous package [GeoUtils](https://github.com/adehecq/geoutils_old), 19 | - the class [pybob.GeoImg](https://github.com/iamdonovan/pybob/blob/master/pybob/GeoImg.py), 20 | - the package [pygeotools](https://github.com/dshean/pygeotools), and 21 | - the package [salem](https://github.com/fmaussion/salem). 22 | 23 | The initial core development of GeoUtils was mainly performed by members of the Glaciology group of the _Laboratory of Hydraulics, Hydrology and 24 | Glaciology (VAW)_ at ETH Zürich3 and of the _University of Fribourg_, both in Switzerland. The package also received contributions by members of 25 | the _University of Oslo_, Norway, the _University of Washington_, US and _Université Grenobles Alpes_, France. 26 | 27 | ```{margin} 28 | 3Check-out [glaciology.ch](https://glaciology.ch) on our founding group of VAW glaciology! 29 | ``` 30 | 31 | ## Joining effort with **demcompare** 32 | 33 | In 2024, GeoUtils and [demcompare](https://github.com/CNES/demcompare) joined efforts in the perspective of 34 | merging the best of both packages into one, and to jointly continue the development of new features for 35 | analyzing geospatial and in particular elevation data with a larger expertise pool. 36 | 37 | [demcompare](https://github.com/CNES/demcompare) is a tool developed by the CNES (French Space Agency) to 38 | support its 3D satellite missions in analyzing elevation data, for instance from stereophotogrammetric DEMs 39 | that can be generated with [CARS](https://github.com/CNES/cars). 40 | 41 | ## Current team 42 | 43 | ```{margin} 44 | 3More on CNES's 3D missions on the [CO3D constellation page](https://cnes.fr/en/projects/co3d). 45 | ``` 46 | 47 | The current lead development team includes **researchers in Earth observation and engineers from 48 | [CNES](https://cnes.fr/en)** (French Space Agency). We specialize in elevation data analysis, for application in Earth 49 | science or for operational use for 3D satellite missions3. 50 | 51 | Other volunteer contributors span diverse scientific backgrounds in industry or research. We welcome 52 | any new contributors! See how to contribute on [the dedicated page of our repository](https://github.com/GlacioHack/xdem/blob/main/CONTRIBUTING.md). 53 | -------------------------------------------------------------------------------- /geoutils/_config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Setup of runtime-compile configuration of GeoUtils.""" 20 | 21 | from __future__ import annotations 22 | 23 | import configparser 24 | import os 25 | from typing import Any 26 | 27 | # The setup is inspired by that of Matplotlib and Geowombat 28 | # https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/rcsetup.py 29 | # https://github.com/jgrss/geowombat/blob/main/src/geowombat/config.py 30 | 31 | _config_ini_file = os.path.abspath(os.path.join(os.path.dirname(__file__), "config.ini")) 32 | 33 | # Validators: to check the format of user inputs 34 | 35 | 36 | def validate_bool(b: bool | str | int) -> bool: 37 | """Convert b to ``bool`` or raise.""" 38 | if isinstance(b, str): 39 | b = b.lower() 40 | if b in ("t", "y", "yes", "on", "true", "1", 1, True): 41 | return True 42 | elif b in ("f", "n", "no", "off", "false", "0", 0, False): 43 | return False 44 | else: 45 | raise ValueError(f"Cannot convert {b!r} to bool") 46 | 47 | 48 | # Map the parameter names with a validating function to check user input 49 | _validators = { 50 | "shift_area_or_point": validate_bool, 51 | "warn_area_or_point": validate_bool, 52 | } 53 | 54 | 55 | class GeoUtilsConfigDict(dict): # type: ignore 56 | """Class for a GeoUtils config dictionary""" 57 | 58 | def __setitem__(self, k: str, v: Any) -> None: 59 | """We override setitem to check user input.""" 60 | 61 | validate_func = _validators[k] 62 | new_value = validate_func(v) 63 | super().__setitem__(k, new_value) 64 | 65 | def _set_defaults(self, path_init_file: str) -> None: 66 | """A function to set""" 67 | 68 | config_parser = configparser.ConfigParser() 69 | config_parser.read(path_init_file) 70 | 71 | for section in config_parser.sections(): 72 | for k, v in config_parser[section].items(): 73 | # Select validator function and update dictionary 74 | validate_func = _validators[k] 75 | self.__setitem__(k, validate_func(v)) 76 | 77 | 78 | # Generate default config dictionary 79 | config = GeoUtilsConfigDict() 80 | config._set_defaults(path_init_file=_config_ini_file) 81 | -------------------------------------------------------------------------------- /doc/source/core_lazy_load.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | (core-lazy-load)= 14 | 15 | # Implicit lazy loading 16 | 17 | Lazy loading, also known as "call-by-need", is the delay in loading or evaluating a dataset. 18 | 19 | In GeoUtils, we implicitly load and pass only metadata until the data is actually needed, and are working to implement lazy analysis tools relying on other packages. 20 | 21 | ## Lazy instantiation of {class}`Rasters` 22 | 23 | By default, GeoUtils instantiate a {class}`~geoutils.Raster` from an **on-disk** file without loading its {attr}`geoutils.Raster.data` array. It only loads its 24 | metadata ({attr}`~geoutils.Raster.transform`, {attr}`~geoutils.Raster.crs`, {attr}`~geoutils.Raster.nodata` and derivatives, as well as 25 | {attr}`~geoutils.Raster.name` and {attr}`~geoutils.Raster.driver`). 26 | 27 | ```{code-cell} ipython3 28 | 29 | import geoutils as gu 30 | 31 | # Instantiate a raster from a filename on disk 32 | filename_rast = gu.examples.get_path("everest_landsat_b4") 33 | rast = gu.Raster(filename_rast) 34 | 35 | # This raster is not loaded 36 | rast 37 | ``` 38 | 39 | To load the data explicitly during instantiation opening, `load_data=True` can be passed to {class}`~geoutils.Raster`. Or the {func}`~geoutils.Raster.load` 40 | method can be called after. The two are equivalent. 41 | 42 | ```{code-cell} ipython3 43 | # Initiate another raster just for the purpose of loading 44 | rast_to_load = gu.Raster(gu.examples.get_path("everest_landsat_b4")) 45 | rast_to_load.load() 46 | 47 | # This raster is loaded 48 | rast_to_load 49 | ``` 50 | 51 | ## Lazy passing of georeferencing metadata 52 | 53 | Operations relying on georeferencing metadata of {class}`Rasters` or {class}`Vectors` are always done by respecting the 54 | possible lazy loading of the objects. 55 | 56 | For instance, using any {class}`~geoutils.Raster` or {class}`~geoutils.Vector` as a match-reference for a geospatial operation (see {ref}`core-match-ref`) will 57 | always conserve the lazy loading of that match-reference object. 58 | 59 | ```{code-cell} ipython3 60 | --- 61 | mystnb: 62 | output_stderr: show 63 | --- 64 | 65 | # Use a smaller Raster as reference to crop the initial one 66 | smaller_rast = gu.Raster(gu.examples.get_path("everest_landsat_b4_cropped")) 67 | rast.crop(smaller_rast) 68 | 69 | # The reference raster is not loaded 70 | smaller_rast 71 | ``` 72 | 73 | ## Optimized geospatial subsetting 74 | 75 | ```{important} 76 | These features are a work in progress, we aim to make GeoUtils more lazy-friendly through [Dask](https://docs.dask.org/en/stable/) in future versions of the 77 | package! 78 | ``` 79 | 80 | Some georeferencing operations can be done without loading the entire array. Right now, relying directly on Rasterio, GeoUtils supports optimized subsetting 81 | through the {func}`~geoutils.Raster.crop` method. 82 | 83 | ```{code-cell} ipython3 84 | # The previously cropped Raster was loaded without accessing the entire array 85 | rast 86 | ``` 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python 2 | # Edit at https://www.gitignore.io/?templates=python 3 | 4 | # pycharm 5 | .idea/ 6 | 7 | # sphinx documentation 8 | doc/_build/ 9 | 10 | ### Python ### 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # celery beat schedule file 103 | celerybeat-schedule 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # Emacs backup files 136 | *~ 137 | 138 | # Version file 139 | geoutils/_version.py 140 | 141 | # End of https://www.gitignore.io/api/python 142 | .vim/ 143 | doc/source/file.txt 144 | doc/source/io_examples/ 145 | doc/source/handling_examples/ 146 | doc/source/analysis_examples/ 147 | doc/source/gen_modules/ 148 | 149 | # Directories where example data is downloaded 150 | examples/data/ 151 | 152 | # Directory where myst_nb executes jupyter code 153 | doc/jupyter_execute/ 154 | doc/source/sg_execution_times.rst 155 | 156 | # Files that should have been deleted by Sphinx at end of build (but can exist if build fails) 157 | examples/io/open_save/myraster.tif 158 | examples/io/open_save/myvector.gpkg 159 | examples/io/open_save/mypc.gpkg 160 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | --- 4 | © 2025 **GeoUtils developers**. 5 | 6 | **GeoUtils** is licensed under permissive Apache 2 license (See LICENSE file). 7 | 8 | All contributors listed in this document are part of the **GeoUtils developers**, and their 9 | contributions are subject to the project's copyright under the terms of the 10 | [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). 11 | 12 | This file keeps track of authors contributions. 13 | 14 | 15 | ## Maintainers / Steering committee 16 | 17 | --- 18 | 19 | | Full name | GitHub | Affiliation | Email | 20 | |----------------------------------|--------------------------------------------|--------------------------------|--------------------------------------------------------------| 21 | | **Romain Hugonnet** | [@rhugonnet](https://github.com/rhugonnet) | University of Alaska Fairbanks | [📧](mailto:romain.hugonnet@gmail.com) | 22 | | **Amaury Dehecq** | [@adehecq](https://github.com/adehecq) | Université Grenoble Alpes, IRD | N/A | 23 | | **Andrew Tedstone** | [@atedstone](https://github/atedstone) | University of Lausanne | N/A | 24 | | **Valentine Bellet** | [@belletva](https://github.com/belletva) | CNES (French Space Agency) | [📧](mailto:valentine.bellet@cnes.fr) | 25 | | **Alice de Bardonnèche-Richard** | [@adebardo](https://github.com/adebardo) | CS Group | [📧](mailto:alice.de-bardonneche-richard@cs-soprasteria.com) | 26 | 27 | ## Emeritus maintainers 28 | 29 | --- 30 | 31 | | Full name | GitHub | Affiliation | Email | 32 | |----------------------------|------|----------------------------|--------------------------------------------------------------| 33 | | **Erik Schytt Mannerfelt** | [@erikmannerfelt](https://github.com/erikmannerfelt) | University of Oslo | N/A | 34 | | **Emmanuel Dubois** | [@duboise-cnes](https://github.com/duboise-cnes) | CNES (French Space Agency) | [📧](mailto:emmanuel.dubois@cnes.fr) | 35 | 36 | ## Contributors 37 | 38 | --- 39 | 40 | - **Valentin Schaffner** [@vschaffn](https://github/vschaffn) 41 | - **Bob McNabb** [@iamdonovan](https://github/iamdonovan) 42 | - **Eli Schwat** [@elischwat](https://github.com/elischwat) 43 | - **Marine Bouchet** [@marinebcht](https://github.com/marinebcht) 44 | - **Amelie Froessl** [@ameliefroessl](https://github.com/ameliefroessl) 45 | - **Friedrich Knuth** [@friedrichknuth](https://github/friedrichknuth) 46 | - **Adrien Wehrlé** [@AdrienWehrle](https://github.com/AdrienWehrle) 47 | - **Fabien Maussion** [@fmaussion](https://github.com/fmaussion) 48 | - **Johannes Landmann** [@jlandmann](https://github/jlandmann) 49 | 50 | ## Original creators 51 | 52 | --- 53 | 54 | - **Romain Hugonnet** [@rhugonnet](https://github.com/rhugonnet) 55 | - **Amaury Dehecq** [@adehecq](https://github/adehecq) 56 | - **Erik Schytt Mannerfelt** [@erikmannerfelt](https://github/erikmannerfelt) 57 | - **Andrew Tedstone** [@atedstone](https://github/atedstone) 58 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the example files used for testing and documentation 3 | """ 4 | 5 | import hashlib 6 | import warnings 7 | 8 | import pytest 9 | 10 | import geoutils as gu 11 | from geoutils import examples 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "example", ["everest_landsat_b4", "everest_landsat_b4_cropped", "everest_landsat_rgb", "exploradores_aster_dem"] 16 | ) # type: ignore 17 | def test_read_paths_raster(example: str) -> None: 18 | assert isinstance(gu.Raster(examples.get_path(example)), gu.Raster) 19 | assert isinstance(gu.Raster(examples.get_path_test(example)), gu.Raster) 20 | 21 | 22 | @pytest.mark.parametrize("example", ["everest_rgi_outlines", "exploradores_rgi_outlines"]) # type: ignore 23 | def test_read_paths_vector(example: str) -> None: 24 | warnings.simplefilter("error") 25 | assert isinstance(gu.Vector(examples.get_path(example)), gu.Vector) 26 | assert isinstance(gu.Vector(examples.get_path_test(example)), gu.Vector) 27 | 28 | 29 | # Original sha256 obtained with `sha256sum filename` 30 | original_sha256_examples = { 31 | "everest_landsat_b4": "271fa34e248f016f87109c8e81960caaa737558fbae110ec9e0d9e2d30d80c26", 32 | "everest_landsat_b4_cropped": "0e63d8e9c4770534a1ec267c91e80cd9266732184a114f0bd1aadb5a613215e6", 33 | "everest_landsat_rgb": "7d0505a8610fd7784cb71c03e5b242715cd1574e978c2c86553d60fd82372c30", 34 | "everest_rgi_outlines": "d1a5bcd4bd4731a24c2398c016a6f5a8064160fedd5bab10609adacda9ba41ef", 35 | "exploradores_aster_dem": "dcb0d708d042553cdd2bb4fd82c55b5674a5e0bd6ea46f1a021b396b7d300033", 36 | "exploradores_rgi_outlines": "19c2dac089ce57373355213fdf2fd72f601bf97f21b04c4920edb1e4384ae2b2", 37 | "coromandel_lidar": "2f1fff1bb84860a8438e14d39e14bf974236dc6345e64649a131507d0ed844f3", 38 | } 39 | 40 | 41 | @pytest.mark.parametrize("example", examples.available) # type: ignore 42 | def test_data_integrity__examples(example: str) -> None: 43 | """ 44 | Test that input data is not corrupted by checking sha265 sum 45 | """ 46 | # Read file as bytes 47 | fbytes = open(examples.get_path(example), "rb").read() 48 | 49 | # Get sha256 50 | file_sha256 = hashlib.sha256(fbytes).hexdigest() 51 | 52 | assert file_sha256 == original_sha256_examples[example] 53 | 54 | 55 | original_sha256_test = { 56 | "everest_landsat_b4": "5aa1a0a1c17efd211e42218ab5e2f3e0e404b96ba5055ac5eebef756ad5c65bc", 57 | "everest_landsat_b4_cropped": "4244767c31c51f7c7b5fb8eb48df7d6394aa707deb9fe699d5672ff9d2507aef", 58 | "everest_landsat_rgb": "b77109f8027418cdd36ccab34cc3996bbbf2756b116ecf0fed8e4163cd7aa2f9", 59 | "everest_rgi_outlines": "3642e2fa5da1d9cad2378e0941985ae47077dba7839e30fdd413f8e868ab2ade", 60 | "exploradores_aster_dem": "c98f24cb131810dd8b2f4773a8df0821cf31edec79890967a67b8a6fdb89314d", 61 | "exploradores_rgi_outlines": "2f0281b00a49ad2f0874fb4ee54df1e0d11ad073f826d9ca713430588c15fa15", 62 | "coromandel_lidar": "95af5de14205c712e7674723d00119f4fa6239a65fb2aa3f7035254ace3194ae", 63 | } 64 | 65 | 66 | @pytest.mark.parametrize("example_test", examples.available_test) # type: ignore 67 | def test_data_integrity__tests(example_test: str) -> None: 68 | """ 69 | Test that input data is not corrupted by checking sha265 sum 70 | """ 71 | # Read file as bytes 72 | fbytes = open(examples.get_path_test(example_test), "rb").read() 73 | 74 | # Get sha256 75 | file_sha256 = hashlib.sha256(fbytes).hexdigest() 76 | 77 | assert file_sha256 == original_sha256_test[example_test] 78 | -------------------------------------------------------------------------------- /tests/test_stats/test_sampling.py: -------------------------------------------------------------------------------- 1 | """Test sampling statistical tools.""" 2 | 3 | from __future__ import annotations 4 | 5 | import numpy as np 6 | import pytest 7 | 8 | import geoutils as gu 9 | from geoutils._typing import NDArrayNum 10 | 11 | 12 | class TestSampling: 13 | """ 14 | Different examples of 1D to 3D arrays with masked values for testing. 15 | """ 16 | 17 | # Case 1 - 1D array, 1 masked value 18 | array1D = np.ma.masked_array(np.arange(10), mask=np.zeros(10)) 19 | array1D.mask[3] = True 20 | assert np.ndim(array1D) == 1 21 | assert np.count_nonzero(array1D.mask) > 0 22 | 23 | # Case 2 - 2D array, 1 masked value 24 | array2D = np.ma.masked_array(np.arange(9).reshape((3, 3)), mask=np.zeros((3, 3))) 25 | array2D.mask[0, 1] = True 26 | assert np.ndim(array2D) == 2 27 | assert np.count_nonzero(array2D.mask) > 0 28 | 29 | # Case 3 - 3D array, 1 masked value 30 | array3D = np.ma.masked_array(np.arange(9).reshape((1, 3, 3)), mask=np.zeros((1, 3, 3))) 31 | array3D = np.ma.vstack((array3D, array3D + 10)) 32 | array3D.mask[0, 0, 1] = True 33 | assert np.ndim(array3D) == 3 34 | assert np.count_nonzero(array3D.mask) > 0 35 | 36 | @pytest.mark.parametrize("array", [array1D, array2D, array3D]) # type: ignore 37 | def test_subsample(self, array: NDArrayNum) -> None: 38 | """ 39 | Test gu.stats.subsample_array. 40 | """ 41 | # Test that subsample > 1 works as expected, i.e. output 1D array, with no masked values, or selected size 42 | for npts in np.arange(2, np.size(array)): 43 | random_values = gu.stats.subsample_array(array, subsample=npts) 44 | assert np.ndim(random_values) == 1 45 | assert np.size(random_values) == npts 46 | assert np.count_nonzero(random_values.mask) == 0 47 | 48 | # Test if subsample > number of valid values => return all 49 | random_values = gu.stats.subsample_array(array, subsample=np.size(array) + 3) 50 | assert np.all(np.sort(random_values) == array[~array.mask]) 51 | 52 | # Test if subsample = 1 => return all valid values 53 | random_values = gu.stats.subsample_array(array, subsample=1) 54 | assert np.all(np.sort(random_values) == array[~array.mask]) 55 | 56 | # Check that order is preserved for subsample = 1 (no random sampling, simply returns valid mask) 57 | random_values_2 = gu.stats.subsample_array(array, subsample=1) 58 | assert np.array_equal(random_values, random_values_2) 59 | 60 | # Test if subsample < 1 61 | random_values = gu.stats.subsample_array(array, subsample=0.5) 62 | assert np.size(random_values) == int(np.count_nonzero(~array.mask) * 0.5) 63 | 64 | # Test with optional argument return_indices 65 | indices = gu.stats.subsample_array(array, subsample=0.3, return_indices=True) 66 | assert np.ndim(indices) == 2 67 | assert len(indices) == np.ndim(array) 68 | assert np.ndim(array[indices]) == 1 69 | assert np.size(array[indices]) == int(np.count_nonzero(~array.mask) * 0.3) 70 | 71 | # Check that we can pass an integer to fix the random state 72 | sub42 = gu.stats.subsample_array(array, subsample=10, random_state=42) 73 | # Check by passing a generator directly 74 | rng = np.random.default_rng(42) 75 | sub42_gen = gu.stats.subsample_array(array, subsample=10, random_state=rng) 76 | # Both should be equal 77 | assert np.array_equal(sub42, sub42_gen) 78 | -------------------------------------------------------------------------------- /doc/source/distance_ops.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | (distance-ops)= 14 | # Distance operations 15 | 16 | Computing distance between sets of geospatial data or manipulating their shape based on distance is often important 17 | for later analysis. To facilitate this type of operations, GeoUtils implements distance-specific functionalities 18 | for both vectors and rasters. 19 | 20 | ```{code-cell} ipython3 21 | :tags: [remove-cell] 22 | 23 | # To get a good resolution for displayed figures 24 | from matplotlib import pyplot 25 | pyplot.rcParams['figure.dpi'] = 600 26 | pyplot.rcParams['savefig.dpi'] = 600 27 | pyplot.rcParams['font.size'] = 9 28 | ``` 29 | 30 | ```{tip} 31 | It is often important to compute distances in a metric CRS. For this, reproject (with 32 | {func}`~geoutils.Raster.reproject`) to a local metric CRS (that can be estimated with {func}`~geoutils.Raster.get_metric_crs`). 33 | ``` 34 | 35 | ## Proximity 36 | 37 | Proximity corresponds to **the distance to the closest target geospatial data**, computed on each pixel of a raster's grid. 38 | The target geospatial data can be either a vector or a raster. 39 | 40 | {func}`geoutils.Raster.proximity` and {func}`geoutils.Vector.proximity` 41 | 42 | ```{code-cell} ipython3 43 | :tags: [hide-cell] 44 | :mystnb: 45 | : code_prompt_show: "Show the code for opening example files" 46 | : code_prompt_hide: "Hide the code for opening example files" 47 | 48 | import matplotlib.pyplot as plt 49 | import geoutils as gu 50 | import numpy as np 51 | 52 | rast = gu.Raster(gu.examples.get_path("everest_landsat_b4")) 53 | rast.set_nodata(0) # Annoying to have to do this here, should we update it in the example? 54 | vect = gu.Vector(gu.examples.get_path("everest_rgi_outlines")) 55 | ``` 56 | 57 | ```{code-cell} ipython3 58 | # Compute proximity to vector outlines 59 | proximity = vect.proximity(rast) 60 | ``` 61 | 62 | ```{code-cell} ipython3 63 | :tags: [hide-input] 64 | :mystnb: 65 | : code_prompt_show: "Show the code for plotting the figure" 66 | : code_prompt_hide: "Hide the code for plotting the figure" 67 | 68 | f, ax = plt.subplots(1, 2) 69 | ax[0].set_title("Raster and vector") 70 | rast.plot(ax=ax[0], cmap="gray", add_cbar=False) 71 | vect.plot(ref_crs=rast, ax=ax[0], ec="k", fc="none") 72 | ax[1].set_title("Proximity") 73 | proximity.plot(ax=ax[1], cmap="viridis", cbar_title="Distance to outlines (m)") 74 | _ = ax[1].set_yticklabels([]) 75 | plt.tight_layout() 76 | ``` 77 | 78 | ## Buffering without overlap 79 | 80 | Buffering consists in **expanding or collapsing vector geometries equally in all directions**. However, this can often lead to overlap 81 | between shapes, which is sometimes undesirable. Using Voronoi polygons, we provide a buffering method without overlap. 82 | 83 | {func}`geoutils.Vector.buffer_without_overlap` 84 | 85 | ```{code-cell} ipython3 86 | # Compute buffer without overlap from vector exterior 87 | vect_buff_nolap = vect.buffer_without_overlap(buffer_size=500) 88 | ``` 89 | 90 | ```{code-cell} ipython3 91 | :tags: [hide-input] 92 | :mystnb: 93 | : code_prompt_show: "Show the code for plotting the figure" 94 | : code_prompt_hide: "Hide the code for plotting the figure" 95 | 96 | # Plot with color to see that the attributes are retained for every feature 97 | vect.plot(ax="new", ec="k", column="Area", alpha=0.5, add_cbar=False) 98 | vect_buff_nolap.plot(column="Area", cbar_title="Buffer around initial features\ncolored by glacier area (km)") 99 | ``` 100 | -------------------------------------------------------------------------------- /GOVERNANCE.md: -------------------------------------------------------------------------------- 1 | # Governance Policy 2 | 3 | This document provides the governance policy for the Project. Maintainers agree to this policy and to abide by all Project polices, 4 | including the [code of conduct](./CODE_OF_CONDUCT.md) and by adding their name to the [AUTHORS.md file](./AUTHORS.md). 5 | 6 | ## 1. Roles. 7 | 8 | This project may include the following roles. Additional roles may be adopted and documented by the Project. 9 | 10 | **1.1. Maintainers**. Maintainers are responsible for organizing activities around developing, maintaining, and updating 11 | the Project. Maintainers are also responsible for determining consensus. This Project may add or remove Maintainers with 12 | the approval of the current Maintainers. All past Maintainers will be listed as an Emeritus maintainer, and may rejoin 13 | at any time. 14 | 15 | **1.2. Contributors**. Contributors are those that have made contributions to the Project. 16 | 17 | ## 2. Decisions. 18 | 19 | **2.1. Consensus-Based Decision Making**. Projects make decisions through consensus of the Maintainers. While explicit 20 | agreement of all Maintainers is preferred, it is not required for consensus. Rather, the Maintainers will determine 21 | consensus based on their good faith consideration of a number of factors, including the dominant view of the 22 | Contributors and nature of support and objections. The Maintainers will document evidence of consensus in accordance 23 | with these requirements. 24 | 25 | **2.2. Appeal Process**. Decisions may be appealed by opening an issue and that appeal will be considered by the 26 | Maintainers in good faith, who will respond in writing within a reasonable time. If the Maintainers deny the appeal, 27 | the appeal may be brought before the Organization Steering Committee, who will also respond in writing in a reasonable 28 | time. 29 | 30 | ## 3. How We Work. 31 | 32 | **3.1. Openness**. Participation is open to anyone who is directly and materially affected by the activity in question. 33 | There shall be no undue financial barriers to participation. 34 | 35 | **3.2. Balance**. The development process should balance the interests of Contributors and other stakeholders. 36 | Contributors from diverse interest categories shall be sought with the objective of achieving balance. 37 | 38 | **3.3. Coordination and Harmonization**. Good faith efforts shall be made to resolve potential conflicts or 39 | incompatibility between releases in this Project. 40 | 41 | **3.4. Consideration of Views and Objections**. Prompt consideration shall be given to the written views and 42 | objections of all Contributors. 43 | 44 | **3.5. Written procedures**. This governance document and other materials documenting this project's development 45 | process shall be available to any interested person. 46 | 47 | ## 4. No Confidentiality. 48 | 49 | Information disclosed in connection with any Project activity, including but not limited to meetings, contributions, 50 | and submissions, is not confidential, regardless of any markings or statements to the contrary. 51 | 52 | ## 5. Trademarks. 53 | 54 | Any names, trademarks, logos, or goodwill developed by and associated with the Project (the "Marks") are controlled by 55 | the Organization. Maintainers may only use these Marks in accordance with the Organization's trademark policy. If a 56 | Maintainer resigns or is removed, any rights the Maintainer may have in the Marks revert to the Organization. 57 | 58 | ## 6. Amendments. 59 | 60 | Amendments to this governance policy may be made by affirmative vote of 2/3 of all Maintainers, with approval by the 61 | Organization's Steering Committee. 62 | 63 | --- 64 | Part of MVG-0.1-beta. 65 | Made with love by GitHub. Licensed under the [CC-BY 4.0 License](https://creativecommons.org/licenses/by/4.0/). 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GeoUtils: consistent geospatial analysis in Python. 2 | 3 | ![](https://readthedocs.org/projects/geoutils/badge/?version=latest) 4 | [![build](https://github.com/GlacioHack/geoutils/actions/workflows/python-tests.yml/badge.svg)](https://github.com/GlacioHack/GeoUtils/actions/workflows/python-tests.yml) 5 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/geoutils.svg)](https://anaconda.org/conda-forge/geoutils) 6 | [![Conda Platforms](https://img.shields.io/conda/pn/conda-forge/geoutils.svg)](https://anaconda.org/conda-forge/geoutils) 7 | [![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/geoutils.svg)](https://anaconda.org/conda-forge/geoutils) 8 | [![PyPI version](https://badge.fury.io/py/geoutils.svg)](https://badge.fury.io/py/geoutils) 9 | [![Coverage Status](https://coveralls.io/repos/github/GlacioHack/geoutils/badge.svg?branch=main)](https://coveralls.io/github/GlacioHack/geoutils?branch=main) 10 | 11 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/GlacioHack/geoutils/main) 12 | [![Pre-Commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) 13 | [![Formatted with black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) 14 | [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 15 | [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 16 | 17 | **GeoUtils** is an open source project to develop a core Python package for geospatial analysis and foster inter-operability between other Python GIS packages. 18 | 19 | It aims at **facilitating end-user geospatial analysis by revolving around consistent `Raster` and `Vector` objects** that effortlessly interface between 20 | themselves. GeoUtils is founded on **implicit loading behaviour**, **robust numerical interfacing** and **convenient object-based methods** to easily perform 21 | the most common higher-level tasks needed by geospatial users. 22 | 23 | If you are looking for an accessible Python package to write the Python equivalent of your [GDAL](https://gdal.org/) command lines, or of your 24 | [QGIS](https://www.qgis.org/en/site/) analysis pipeline **without a steep learning curve** on Python GIS syntax, GeoUtils is perfect for you! For more advanced 25 | users, GeoUtils also aims at being efficient and scalable by supporting lazy loading and parallel computing (ongoing). 26 | 27 | GeoUtils relies on [Rasterio](https://github.com/rasterio/rasterio), [GeoPandas](https://github.com/geopandas/geopandas) and [Pyproj](https://github.com/pyproj4/pyproj) for georeferenced 28 | calculations, and on [NumPy](https://github.com/numpy/numpy) and [Xarray](https://github.com/pydata/xarray) for numerical analysis. It allows easy access to 29 | the functionalities of these packages through interfacing or composition, and quick inter-operability through object conversion. 30 | 31 | ## Documentation 32 | 33 | For a quick start, full feature description or search through the API, see GeoUtils' documentation at: https://geoutils.readthedocs.io. 34 | 35 | ## Installation 36 | 37 | ```bash 38 | mamba install -c conda-forge geoutils 39 | ``` 40 | 41 | See [mamba's documentation](https://mamba.readthedocs.io/en/latest/) to install `mamba`, which will solve your environment much faster than `conda`. 42 | 43 | ## Start contributing 44 | 45 | 1. Fork the repository, make a feature branch and push changes. 46 | 2. When ready, submit a pull request from the feature branch of your fork to `GlacioHack/geoutils:main`. 47 | 3. The PR will be reviewed by at least one maintainer, discussed, then merged. 48 | 49 | More info on [our contributing page](CONTRIBUTING.md). 50 | -------------------------------------------------------------------------------- /doc/source/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: GeoUtils 3 | --- 4 | 5 | ::::{grid} 6 | :reverse: 7 | :gutter: 2 1 1 1 8 | :margin: 4 4 1 1 9 | 10 | :::{grid-item} 11 | :columns: 4 12 | 13 | ```{image} ./_static/logo_only.png 14 | :width: 300px 15 | :class: dark-light 16 | ``` 17 | ::: 18 | 19 | :::{grid-item} 20 | :columns: 8 21 | :class: sd-fs-3 22 | :child-align: center 23 | 24 | GeoUtils is a Python package for **accessible** and **consistent** geospatial analysis. 25 | :::: 26 | 27 | ```{important} 28 | :class: margin 29 | GeoUtils ``v0.2`` is released with more consistent point cloud support! We are working on Xarray and GeoPandas accessors for all data objects, as well as other scalability features. 30 | ``` 31 | 32 | GeoUtils is built on top of core geospatial packages (Rasterio, GeoPandas, PyProj) and numerical packages 33 | (NumPy, Xarray, SciPy) to provide **consistent higher-level functionalities at the interface of raster, vector and point 34 | cloud objects** (such as match-reference reprojection, point interpolation or gridding). 35 | 36 | It is **tailored to perform quantitative analysis that implicitly understands the intricacies of geospatial data** 37 | (nodata values, projection, pixel interpretation), through **an intuitive object-based API to foster accessibility**, 38 | and strives **to be computationally scalable** (Dask support in development for future Xarray accessor). 39 | 40 | If you are looking to **port your GDAL or QGIS workflow in Python**, GeoUtils is made for you! 41 | 42 | ---------------- 43 | 44 | # Where to start? 45 | 46 | ::::{grid} 1 2 2 3 47 | :gutter: 1 1 1 2 48 | 49 | :::{grid-item-card} {material-regular}`edit_note;2em` About GeoUtils 50 | :link: about-geoutils 51 | :link-type: ref 52 | 53 | Learn more about why we developed GeoUtils. 54 | 55 | +++ 56 | [Learn more »](about_geoutils) 57 | ::: 58 | 59 | :::{grid-item-card} {material-regular}`data_exploration;2em` Quick start 60 | :link: quick-start 61 | :link-type: ref 62 | 63 | Run a short example of the package functionalities. 64 | 65 | +++ 66 | [Learn more »](quick_start) 67 | ::: 68 | 69 | :::{grid-item-card} {material-regular}`preview;2em` Features 70 | :link: core-index 71 | :link-type: ref 72 | 73 | Dive into the full documentation. 74 | 75 | +++ 76 | [Learn more »](core_index) 77 | ::: 78 | 79 | :::: 80 | 81 | Prefer to **grasp GeoUtils' core concepts by comparing with other Python packages**? Read through a short **{ref}`side-by-side code comparison with Rasterio and GeoPandas`**. 82 | 83 | Looking to **learn a specific feature by running an example**? Jump straight into our **example galleries on {ref}`examples-io`, {ref}`examples-handling` and {ref}`examples-analysis`**. 84 | 85 | 86 | ```{seealso} 87 | If you are DEM-enthusiastic, **[check-out our sister package xDEM](https://xdem.readthedocs.io/) for digital elevation models.** 88 | ``` 89 | ---------------- 90 | 91 | # Table of contents 92 | 93 | ```{toctree} 94 | :caption: Getting started 95 | :maxdepth: 2 96 | 97 | about_geoutils 98 | how_to_install 99 | quick_start 100 | feature_overview 101 | ``` 102 | 103 | ```{toctree} 104 | :caption: Features 105 | :maxdepth: 2 106 | 107 | core_index 108 | data_object_index 109 | georeferencing 110 | geotransformations 111 | raster_vector_point 112 | distance_ops 113 | stats 114 | ``` 115 | 116 | ```{toctree} 117 | :caption: Examples 118 | :maxdepth: 2 119 | 120 | io_examples/index 121 | handling_examples/index 122 | analysis_examples/index 123 | ``` 124 | 125 | ```{toctree} 126 | :caption: Reference 127 | :maxdepth: 2 128 | 129 | api 130 | cli 131 | config 132 | release_notes 133 | ``` 134 | 135 | ```{toctree} 136 | :caption: Project information 137 | :maxdepth: 2 138 | 139 | credits 140 | ``` 141 | 142 | # Indices and tables 143 | 144 | - {ref}`genindex` 145 | - {ref}`modindex` 146 | - {ref}`search` 147 | -------------------------------------------------------------------------------- /tests/test_stats/test_estimators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the statistical estimator module. 3 | """ 4 | 5 | import numpy as np 6 | import pytest 7 | import scipy 8 | 9 | from geoutils import Raster, examples 10 | from geoutils.stats import linear_error, nmad, rmse, sum_square 11 | 12 | 13 | class TestEstimators: 14 | landsat_b4_path = examples.get_path_test("everest_landsat_b4") 15 | landsat_raster = Raster(landsat_b4_path) 16 | 17 | def test_nmad(self) -> None: 18 | """Test NMAD functionality runs on any type of input""" 19 | 20 | # Check that the NMAD is computed the same with a masked array or NaN array, and is equal to scipy nmad 21 | nmad_ma = nmad(self.landsat_raster.data) 22 | nmad_array = nmad(self.landsat_raster.get_nanarray(floating_dtype="float64")) 23 | nmad_scipy = scipy.stats.median_abs_deviation(self.landsat_raster.data, axis=None, scale="normal") 24 | 25 | assert nmad_ma == nmad_array 26 | assert nmad_ma.round(2) == nmad_scipy.round(2) 27 | 28 | # Check that the scaling factor works 29 | nmad_1 = nmad(self.landsat_raster.data, nfact=1) 30 | nmad_2 = nmad(self.landsat_raster.data, nfact=2) 31 | 32 | assert nmad_1 * 2 == nmad_2 33 | 34 | def test_linear_error(self) -> None: 35 | """Test linear error (LE) functionality runs on any type of input""" 36 | 37 | # Compute LE on the landsat raster data for a default interval (LE90) 38 | le_ma = linear_error(self.landsat_raster.data) 39 | le_nan_array = linear_error(self.landsat_raster.get_nanarray()) 40 | 41 | # Assert the LE90 is computed the same for masked array and NaN array 42 | assert le_ma == le_nan_array 43 | 44 | # Check that the function works for different intervals 45 | le90 = linear_error(self.landsat_raster.data, interval=90) 46 | le50 = linear_error(self.landsat_raster.data, interval=50) 47 | 48 | # Verify that LE50 (interquartile range) is smaller than LE90 49 | assert le50 < le90 50 | 51 | # Test a known dataset 52 | test_data = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 53 | le90_test = linear_error(test_data, interval=90) 54 | le50_test = linear_error(test_data, interval=50) 55 | 56 | assert le90_test == 9 57 | assert le50_test == 5 58 | 59 | # Test masked arrays with invalid data (should ignore NaNs/masked values) 60 | masked_data = np.ma.masked_array(test_data, mask=[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) 61 | le90_masked = linear_error(masked_data, interval=90) 62 | le50_masked = linear_error(masked_data, interval=50) 63 | 64 | assert le90_masked == pytest.approx(4.5) 65 | assert le50_masked == 2.5 66 | 67 | def test_rmse(self) -> None: 68 | """Test RMSE functionality runs on any type of input""" 69 | 70 | test_data = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 71 | # Test masked arrays with invalid data (should ignore NaNs/masked values) 72 | masked_data = np.ma.masked_array(test_data, mask=[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) 73 | # Corresponding array 74 | test_data_crop = np.array([0, 1, 2, 3, 4, 5]) 75 | 76 | rmse_data = rmse(test_data_crop) 77 | rmse_masked_data = rmse(masked_data) 78 | 79 | assert rmse_data == rmse_masked_data 80 | assert rmse_data == pytest.approx(3.0276503540974917) 81 | 82 | def test_sum_square(self) -> None: 83 | """Test Sum Square functionality runs on any type of input""" 84 | 85 | test_data = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 86 | # Test masked arrays with invalid data (should ignore NaNs/masked values) 87 | masked_data = np.ma.masked_array(test_data, mask=[0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) 88 | # Corresponding array 89 | test_data_crop = np.array([0, 1, 2, 3, 4, 5]) 90 | 91 | sum_square_data = sum_square(test_data_crop) 92 | sum_square_masked_data = sum_square(masked_data) 93 | 94 | assert sum_square_data == sum_square_masked_data 95 | assert sum_square_data == 55 96 | -------------------------------------------------------------------------------- /doc/source/quick_start.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | (quick-start)= 14 | 15 | # Quick start 16 | 17 | A short code example using several end-user functionalities of GeoUtils. For a more detailed example of all features, 18 | have a look at the {ref}`feature-overview` page! Or, to find an example about 19 | a specific functionality, jump to {ref}`quick-gallery` right below. 20 | 21 | ```{code-cell} ipython3 22 | :tags: [remove-cell] 23 | 24 | # To get a good resolution for displayed figures 25 | from matplotlib import pyplot 26 | pyplot.rcParams['figure.dpi'] = 600 27 | pyplot.rcParams['savefig.dpi'] = 600 28 | pyplot.rcParams['font.size'] = 9 29 | ``` 30 | 31 | ## Short example 32 | 33 | ```{note} 34 | :class: margin 35 | 36 | Most functions examplified here normally require many lines of code using several independent packages with inconsistent 37 | geospatial syntax and volatile passing of metadata that can lead to errors! 38 | 39 | In GeoUtils, **these higher-level operations are tested to ensure robustness and consistency**. 🙂 40 | ``` 41 | 42 | The package functionalities revolve around the 43 | {class}`~geoutils.Raster` and {class}`~geoutils.Vector` classes, from which most methods can be called. 44 | Below, in a few lines, we load a raster and a vector, crop them to a common extent, re-assign raster values around 45 | a buffer of the vector, perform calculations on the modified raster, and finally plot and save it! 46 | 47 | 48 | ```{margin} 49 |   50 | ``` 51 | 52 | 53 | ```{note} 54 | :class: margin 55 | 56 | **We notice a ``Userwarning``:** No nodata value was defined in the GeoTIFF file, so GeoUtils automatically defined 57 | one compatible with the data type derived during operations. 58 | ``` 59 | 60 | ```{code-cell} ipython3 61 | --- 62 | mystnb: 63 | output_stderr: show 64 | --- 65 | 66 | import geoutils as gu 67 | 68 | # Examples files: paths to a GeoTIFF file and an ESRI shapefile 69 | filename_rast = gu.examples.get_path("everest_landsat_b4") 70 | filename_vect = gu.examples.get_path("everest_rgi_outlines") 71 | 72 | # Open files by instantiating Raster and Vector 73 | # (Rasters are loaded lazily = only metadata but not array unless required) 74 | rast = gu.Raster(filename_rast) 75 | vect = gu.Vector(filename_vect) 76 | 77 | # Crop raster to vector's extent by simply passing vector as "match-reference" 78 | rast = rast.crop(vect) 79 | 80 | # Buffer the vector by 500 meters no matter its current projection system 81 | vect_buff = vect.buffer_metric(500) 82 | 83 | # Create mask of vector on same grid/CRS as raster using it as "match-reference" 84 | mask_buff = vect_buff.create_mask(rast) 85 | 86 | # Re-assign values of pixels in the mask while performing a sum 87 | # (Now the raster loads implicitly) 88 | rast[mask_buff] = rast[mask_buff] + 50 89 | import numpy as np 90 | calc_rast = np.log(rast / 2) + 3.5 91 | 92 | # Plot raster and vector, using raster as projection-reference for vector 93 | calc_rast.plot(cmap='Spectral', cbar_title='My calculation') 94 | vect_buff.plot(calc_rast, fc='none', ec='k', lw=0.5) 95 | 96 | # Save to file 97 | calc_rast.to_file("mycalc.tif") 98 | ``` 99 | 100 | ```{code-cell} ipython3 101 | :tags: [remove-cell] 102 | import os 103 | os.remove("mycalc.tif") 104 | ``` 105 | 106 | (quick-gallery)= 107 | ## More examples 108 | 109 | To dive into more illustrated code, explore our gallery of examples that is composed of: 110 | - An {ref}`examples-io` section on opening, saving, loading, importing and exporting, 111 | - An {ref}`examples-handling` section on geotransformations (crop, reproject) and raster-vector interfacing, 112 | - An {ref}`examples-analysis` section on analysis tools and raster numerics. 113 | 114 | See also the full concatenated list of examples below. 115 | 116 | ```{eval-rst} 117 | .. minigallery:: geoutils.Raster 118 | :add-heading: Examples using rasters and vectors 119 | ``` 120 | -------------------------------------------------------------------------------- /geoutils/stats/estimators.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Module for additional statistical estimators.""" 20 | 21 | from typing import Any 22 | 23 | import numpy as np 24 | from scipy.stats.mstats import mquantiles 25 | 26 | from geoutils._typing import NDArrayNum 27 | 28 | 29 | def nmad(data: NDArrayNum, nfact: float = 1.4826) -> np.floating[Any]: 30 | """ 31 | Calculate the normalized median absolute deviation (NMAD) of an array. 32 | Default scaling factor is 1.4826 to scale the median absolute deviation (MAD) to the dispersion of a normal 33 | distribution (see https://en.wikipedia.org/wiki/Median_absolute_deviation#Relation_to_standard_deviation, and 34 | e.g. Höhle and Höhle (2009), http://dx.doi.org/10.1016/j.isprsjprs.2009.02.003) 35 | 36 | :param data: Input array or raster 37 | :param nfact: Normalization factor for the data 38 | 39 | :returns nmad: (normalized) median absolute deviation of data. 40 | """ 41 | if isinstance(data, np.ma.masked_array): 42 | return nfact * np.ma.median(np.abs(data - np.ma.median(data))) 43 | else: 44 | return nfact * np.nanmedian(np.abs(data - np.nanmedian(data))) 45 | 46 | 47 | def linear_error(data: NDArrayNum, interval: float = 90) -> np.floating[Any]: 48 | """ 49 | Compute the linear error (LE) for a given dataset, representing the range of differences between the upper and 50 | lower percentiles of the data. By default, this calculates the 90% confidence interval (LE90). 51 | 52 | :param data: A numpy array or masked array of data, typically representing the differences (errors) in elevation or 53 | another quantity. 54 | :param interval: The confidence interval to compute, specified as a percentage. For example, an interval of 90 will 55 | compute the range between the 5th and 95th percentiles (LE90). This value must be between 0 and 100. 56 | 57 | return: The computed linear error, which is the difference between the upper and lower percentiles. 58 | 59 | raises: ValueError if the `interval` is not between 0 and 100. 60 | """ 61 | # Validate the interval 62 | if not (0 < interval <= 100): 63 | raise ValueError("Interval must be between 0 and 100") 64 | 65 | max = 50 + interval / 2 66 | min = 50 - interval / 2 67 | if isinstance(data, np.ma.masked_array): 68 | return ( 69 | mquantiles(data, prob=max / 100, alphap=1, betap=1) - mquantiles(data, prob=min / 100, alphap=1, betap=1) 70 | )[0] 71 | else: 72 | return np.nanpercentile(data, max) - np.nanpercentile(data, min) 73 | 74 | 75 | def sum_square(data: NDArrayNum) -> np.floating[Any]: 76 | """ 77 | Calculate the sum of the square of a data array. 78 | 79 | :param data: A numpy array or masked array of data, typically representing the differences (errors) in elevation or 80 | another quantity. 81 | :return: sum square 82 | """ 83 | if np.ma.isMaskedArray(data): 84 | return np.ma.sum(np.square(data)) 85 | else: 86 | return np.nansum(np.square(data)) 87 | 88 | 89 | def rmse(data: NDArrayNum) -> np.floating[Any]: 90 | """ 91 | Calculate the RMSE of a data array. 92 | 93 | :param data: A numpy array or masked array of data, typically representing the differences (errors) in elevation or 94 | another quantity. 95 | :return: rmse 96 | """ 97 | if np.ma.isMaskedArray(data): 98 | return np.sqrt(np.ma.mean(np.square(data))) 99 | else: 100 | return np.sqrt(np.nanmean(np.square(data))) 101 | -------------------------------------------------------------------------------- /geoutils/stats/sampling.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Module for array sampling statistics.""" 20 | 21 | from __future__ import annotations 22 | 23 | from typing import Literal, overload 24 | 25 | import numpy as np 26 | 27 | from geoutils._typing import MArrayNum, NDArrayNum 28 | from geoutils.raster.array import get_mask_from_array 29 | 30 | 31 | @overload 32 | def subsample_array( 33 | array: NDArrayNum | MArrayNum, 34 | subsample: float | int, 35 | return_indices: Literal[False] = False, 36 | *, 37 | random_state: int | np.random.Generator | None = None, 38 | ) -> NDArrayNum: ... 39 | 40 | 41 | @overload 42 | def subsample_array( 43 | array: NDArrayNum | MArrayNum, 44 | subsample: float | int, 45 | return_indices: Literal[True], 46 | *, 47 | random_state: int | np.random.Generator | None = None, 48 | ) -> tuple[NDArrayNum, ...]: ... 49 | 50 | 51 | @overload 52 | def subsample_array( 53 | array: NDArrayNum | MArrayNum, 54 | subsample: float | int, 55 | return_indices: bool = False, 56 | random_state: int | np.random.Generator | None = None, 57 | ) -> NDArrayNum | tuple[NDArrayNum, ...]: ... 58 | 59 | 60 | def subsample_array( 61 | array: NDArrayNum | MArrayNum, 62 | subsample: float | int, 63 | return_indices: bool = False, 64 | random_state: int | np.random.Generator | None = None, 65 | ) -> NDArrayNum | tuple[NDArrayNum, ...]: 66 | """ 67 | Randomly subsample a 1D or 2D array by a sampling factor, taking only non NaN/masked values. 68 | 69 | :param array: Input array. 70 | :param subsample: Subsample size. If <= 1, will be considered a fraction of valid pixels to extract. 71 | If > 1 will be considered the number of pixels to extract. 72 | :param return_indices: If set to True, will return the extracted indices only. 73 | :param random_state: Random state, or seed number to use for random calculations (for testing) 74 | 75 | :returns: The subsampled array (1D) or the indices to extract (same shape as input array) 76 | """ 77 | # Define state for random sampling (to fix results during testing) 78 | rng = np.random.default_rng(random_state) 79 | 80 | # Remove invalid values and flatten array 81 | mask = get_mask_from_array(array) # -> need to remove .squeeze in get_mask 82 | valids = np.argwhere(~mask.flatten()).squeeze() 83 | 84 | # Get number of points to extract 85 | # If subsample is one, we don't perform any subsampling operation, we return the valid array or indices directly 86 | if subsample == 1: 87 | unraveled_indices = np.unravel_index(valids, array.shape) 88 | if return_indices: 89 | return unraveled_indices 90 | else: 91 | return array[unraveled_indices] 92 | if (subsample <= 1) & (subsample > 0): 93 | npoints = int(subsample * np.count_nonzero(~mask)) 94 | elif subsample > 1: 95 | npoints = int(subsample) 96 | else: 97 | raise ValueError("`subsample` must be > 0") 98 | 99 | # Checks that array and npoints are correct 100 | assert np.ndim(valids) == 1, "Something is wrong with array dimension, check input data and shape" 101 | if npoints > np.size(valids): 102 | npoints = np.size(valids) 103 | 104 | # Randomly extract npoints without replacement 105 | indices = rng.choice(valids, npoints, replace=False) 106 | unraveled_indices = np.unravel_index(indices, array.shape) 107 | 108 | if return_indices: 109 | return unraveled_indices 110 | else: 111 | return array[unraveled_indices] 112 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 GeoUtils developers. 2 | 3 | This file is part of GeoUtils (see https://github.com/GlacioHack/geoutils). 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | GeoUtils software is distributed under the Apache Software License (ASL) v2.0, see 18 | LICENSE file or http://www.apache.org/licenses/LICENSE-2.0 for details. 19 | 20 | Python: programming language that lets you work quickly and integrate systems more effectively. 21 | Copyright (c) 2001-2023 Python Software Foundation. All Rights reserved. 22 | Website: http://python.org/ 23 | License: Python Software License. 24 | 25 | NumPy: The fundamental package for scientific computing with Python. 26 | Copyright (c) 2005-2024, NumPy Developers. 27 | Website: https://numpy.org/ 28 | License: BSD 3-Clause. 29 | 30 | Matplotlib: Comprehensive library for creating static, animated, and interactive visualizations in Python. 31 | Copyright (C) 2001-2023 Matplotlib Development Team. 32 | Website: https://matplotlib.org/ 33 | License: Matplotlib only uses BSD compatible code, and its license is based on the PSF license. 34 | 35 | SciPy: Open-source software for mathematics, science, and engineering. 36 | Copyright (c) 2001-2002 Enthought, Inc. All rights reserved. 37 | Copyright (c) 2003-2019 SciPy Developers. All rights reserved. 38 | Website: https://www.scipy.org/scipylib/ 39 | License: BSD 3-Clause. 40 | 41 | Rasterio: Access to geospatial raster data 42 | Copyright (c) 2016, MapBox All rights reserved. 43 | Website: https://github.com/mapbox/rasterio 44 | License: BSD 3-Clause. 45 | 46 | GeoPandas: Python tools for geographic data. 47 | Copyright (c) 2013-2022, GeoPandas developers. 48 | Website: https://geopandas.org/ 49 | License: BSD 3-Clause. 50 | 51 | pandas: Data analysis and manipulation library for Python. 52 | Copyright (c) 2008-2011, AQR Capital Management, LLC, Lambda Foundry, Inc. and PyData Development Team. 53 | Copyright (c) 2011-2024, Open source contributors. 54 | Website: https://pandas.pydata.org/ 55 | License: BSD 3-Clause. 56 | 57 | Xarray: N-D labeled arrays and datasets in Python. 58 | Copyright 2014-2025, xarray Developers. 59 | Website: http://xarray.pydata.org 60 | License: Apache License 2.0. 61 | 62 | rioxarray: Geospatial xarray extension powered by rasterio. 63 | Copyright 2019-2023, Corteva Agriscience. 64 | Website: https://corteva.github.io/rioxarray 65 | License: Apache License 2.0. 66 | 67 | scikit-image: Image processing in Python. 68 | Copyright (c) 2009-2022 the scikit-image team. 69 | Website: https://scikit-image.org/ 70 | License: BSD 3-Clause. 71 | 72 | affine: Matrix transformations for geospatial coordinates. 73 | Copyright (c) 2014-2023, Sean Gillies. 74 | Website: https://github.com/sgillies/affine 75 | License: BDS 3-Clause. 76 | 77 | Shapely: Manipulation and analysis of geometric objects. 78 | Copyright (c) 2007, Sean C. Gillies. 2019, Casper van der Wel. 2007-2022, Shapely Contributors. 79 | Website: https://shapely.readthedocs.io/ 80 | License: BSD 3-Clause. 81 | 82 | pyproj: Python interface to PROJ (cartographic projections and transformations library). 83 | Copyright (c) 2006-2018, Jeffrey Whitaker. 84 | Copyright (c) 2019-2024, Open source contributors. 85 | Website: https://pyproj4.github.io/pyproj/stable/ 86 | License: MIT License. 87 | 88 | tqdm: A fast, extensible progress bar for Python an CLI applications. 89 | Copyright (c) MIT 2013 Noam Yorav-Raphael, original author. 90 | Copyright (c) MPL-2.0 2015-2024 Casper da Costa-Luis. 91 | Website: https://github.com/tqdm/tqdm 92 | License: MPL-2.0 and MIT License. 93 | 94 | dask: Parallel computing with task scheduling. 95 | Copyright (c) 2025 Dask core developers. 96 | Website: https://www.dask.org/ 97 | License: New BSD License. 98 | 99 | Numba: A Just-in-Time Compiler for Python that accelerates numerical functions. 100 | Copyright (c) 2012-2023 Anaconda, Inc. 101 | Website: https://numba.pydata.org/ 102 | License: BSD 2-Clause. 103 | -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | test: 14 | name: ${{ matrix.os }}, python ${{ matrix.python-version }}, ${{ matrix.dep-level }} 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | os: ["ubuntu-latest", "macos-latest"] 20 | python-version: ["3.10", "3.11", "3.12", "3.13"] 21 | dep-level: ["base", "opt"] 22 | 23 | defaults: 24 | run: 25 | shell: bash -l {0} 26 | 27 | steps: 28 | - uses: actions/checkout@v6 29 | 30 | # We initiate the environment empty, and check if a key for this environment doesn't already exist in the cache 31 | - name: Initiate empty environment 32 | uses: conda-incubator/setup-miniconda@v3 33 | with: 34 | miniforge-version: latest 35 | auto-update-conda: true 36 | use-mamba: true 37 | channel-priority: strict 38 | mamba-version: "2.0.5" 39 | activate-environment: geoutils-dev 40 | python-version: 41 | 42 | - name: Get month for resetting cache 43 | id: get-date 44 | run: echo "cache_date=$(/bin/date -u '+%Y%m')" >> $GITHUB_ENV 45 | shell: bash 46 | 47 | - name: Cache conda env 48 | uses: actions/cache@v5 49 | with: 50 | path: ${{ env.CONDA }}/envs 51 | key: conda-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.dep-level }}-${{ env.cache_date }}-${{ hashFiles('dev-environment.yml') }}-${{ env.CACHE_NUMBER }} 52 | env: 53 | CACHE_NUMBER: 1 # Increase this value to reset cache if environment.yml has not changed 54 | id: cache 55 | 56 | # The trick below is necessary because the generic environment file does not specify a Python version, and ONLY 57 | # "conda env update" CAN BE USED WITH CACHING, which upgrades the Python version when using the base environment 58 | # (we add "graphviz" from dev-environment to solve all dependencies at once, at graphviz relies on image 59 | # processing packages very much like geo-packages; not a problem for docs, dev installs where all is done at once) 60 | - name: Install base environment with a fixed Python version 61 | if: steps.cache.outputs.cache-hit != 'true' 62 | run: | 63 | mamba install pyyaml python=${{ matrix.python-version }} 64 | python .github/scripts/generate_yml_env_fixed_py.py --pyv ${{ matrix.python-version }} --add "gdal" "environment.yml" 65 | mamba env update -n geoutils-dev -f environment-ci-py${{ matrix.python-version }}.yml 66 | 67 | # If/else equivalent to install base environment, or base+optional 68 | - name: Install project environment (base) 69 | if: ${{ matrix.dep-level == 'base' }} 70 | run: pip install -e .[test] 71 | 72 | - name: Install project environment (optional) 73 | if: ${{ matrix.dep-level != 'base' }} 74 | run: pip install -e .[test,${{ matrix.dep-level }}] 75 | 76 | - name: Check import works 77 | run: python -c "import geoutils" 78 | 79 | # Stop the build if there are Python syntax errors or undefined names 80 | - name: Lint with flake8 81 | run: | 82 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 83 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 84 | 85 | - name: Print conda environment (for debugging) 86 | run: | 87 | conda info 88 | conda list 89 | 90 | - name: Test with pytest 91 | run: pytest -ra --cov=geoutils/ --cov-report=lcov 92 | 93 | - name: Upload coverage to Coveralls 94 | uses: coverallsapp/github-action@v2 95 | continue-on-error: true 96 | with: 97 | github-token: ${{ secrets.github_token }} 98 | flag-name: run-${{ join(matrix.*, '-') }} 99 | path-to-lcov: coverage.lcov 100 | parallel: true 101 | 102 | finish: 103 | needs: test 104 | runs-on: ubuntu-latest 105 | steps: 106 | - name: Upload to Coveralls finished 107 | uses: coverallsapp/github-action@v2 108 | with: 109 | github-token: ${{ secrets.github_token }} 110 | parallel-finished: true 111 | -------------------------------------------------------------------------------- /doc/source/release_notes.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | Below, the release notes for all minor versions and our roadmap to a first major version. 4 | 5 | ## 0.2.0 6 | 7 | GeoUtils version 0.2 is the **second minor release** since the creation of the project. It is the result of months of work to 8 | consolidate the point cloud features towards a stable API that interfaces well with other objects. Parallel work on scalability 9 | with Dask and Multiprocessing is ongoing, and should soon be released in a 0.3. 10 | 11 | GeoUtils 0.2 adds: 12 | - **A point cloud object** with its own specific methods (e.g, gridding), that can be used as match-reference for other operations (e.g., interpolating at points), and supports arithmetic (e.g., indexing, NumPy stats) and geometric (e.g., masking) functionalities with the same API as for `Raster` objects, 13 | - **Preliminary statistics** features common to rasters and point clouds, which will be expanded with binning and spatial statistics. 14 | 15 | A few changes might be required to adapt from previous versions: 16 | - Specify `Raster.interp_points(as_array=True)` to mirror the previous behaviour of returning a 1D array of interpolated values, otherwise now returns a point cloud by default. 17 | - The `Mask` class is deprecated in favor of `Raster(is_mask=True)` to declare a boolean-type Raster, but should keep working until 0.3. 18 | 19 | ## 0.1.0 20 | 21 | GeoUtils version 0.1 is the **first minor release** since the creation of the project in 2020. It is the result of years of work 22 | to consolidate and re-structure features into a mature and stable API to minimize future breaking changes. 23 | 24 | **All the core features drafted at the start of the project are now supported**, and there is a **clear roadmap 25 | towards a first major release 1.0**. This minor release also adds many tests and improves significantly the documentation 26 | from the early-development state of the package. 27 | 28 | The re-structuring created some breaking changes, though minor. 29 | 30 | See details below, including **a guide to help migrate code from early-development versions**. 31 | 32 | ### Features 33 | 34 | GeoUtils now gathers the following core features: 35 | - **Geospatial data objects** core to quantatiative analysis, which are rasters, vectors and point cloud (preliminary) functionalities, 36 | - **Referencing and transformations** using a consistent API with match-reference functionalities, 37 | - **Raster–vector–point interface** to interface between the core objects, including rasterize and polygonize, interpolate and grid, and conversions, 38 | - **Distance operations** for all objects. 39 | 40 | (migrate-early)= 41 | ### Migrate from early versions 42 | 43 | The following changes **might be required to solve breaking changes**, depending on your early-development version: 44 | - Rename `.show()` to `.plot()` for all data objects, 45 | - Rename `.dtypes` to `dtype` for `Raster` objects, 46 | - Operations `.crop()`, `shift()` and `to_vcrs()` are not done in-place by default anymore, replace by `rst = rst.crop()` or `rst.crop(..., inplace=True)` to mirror the old default behaviour, 47 | - Rename `.shift()` to `.translate()` for `Raster` objects, 48 | - Several function arguments are renamed, in particular `dst_xxx` arguments of `.reproject()` are all renamed to `xxx` e.g. `dst_crs` to `crs`, 49 | - New user warnings are sometimes raised, in particular if some metadata is not properly defined such as `.nodata`. Those should give an indication as how to silence them. 50 | 51 | ## Roadmap to 1.0 52 | 53 | Based on recent and ongoing progress, we envision the following roadmap. 54 | 55 | **Releases of 0.2, 0.3, 0.4, etc**, for the following planned (ongoing) additions: 56 | - The **addition of a point cloud `PointCloud` data object**, inherited from the `Vector` object alongside many features at the interface of point and raster, 57 | - The **addition of a Xarray accessor `rst`** mirroring the `Raster` object, to work natively with Xarray objects and add support on out-of-memory Dask operations for most of GeoUtils' features, 58 | - The **addition of a GeoPandas accessor `pc`** mirroring the `PointCloud` object, to work natively with GeoPandas objects, 59 | - The **addition of statistical features** including zonal statistics (e.g., statistics per vector geometry), grouped statistics (e.g., binning with other variables) and spatial statistics (variogram and kriging) through optional dependencies. 60 | - The **addition of filtering and gap-filling features** natively robust to nodata and working similarly for all type of geospatial objects. 61 | 62 | **Release of 1.0** once all these additions are fully implemented, and after feedback from the community. 63 | -------------------------------------------------------------------------------- /doc/source/stats.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | (stats)= 14 | 15 | # Statistics 16 | 17 | GeoUtils supports statistical analysis tailored to geospatial objects. 18 | 19 | For a {class}`~geoutils.Raster` or a {class}`~geoutils.PointCloud`, the statistics are naturally performed on the {attr}`~geoutils.Raster.data` attribute 20 | which is clearly defined. 21 | 22 | [//]: # (For a {class}`~geoutils.Vector`, statistics have to be performed on a specific column.) 23 | 24 | ```{warning} 25 | The API for statistical features is preliminary and might change with the release of zonal and grouped statistics. 26 | ``` 27 | 28 | ## Estimators 29 | 30 | The {func}`~geoutils.Raster.get_stats` method allows to extract key statistical estimators from a raster or a point cloud, optionally subsetting to an 31 | inlier mask. 32 | 33 | Supported statistics are : 34 | - **Mean:** arithmetic mean of the data, ignoring masked values. 35 | - **Median:** middle value when the valid data points are sorted in increasing order, ignoring masked values. 36 | - **Max:** maximum value among the data, ignoring masked values. 37 | - **Min:** minimum value among the data, ignoring masked values. 38 | - **Sum:** sum of all data, ignoring masked values. 39 | - **Sum of squares:** sum of the squares of all data, ignoring masked values. 40 | - **90th percentile:** point below which 90% of the data falls, ignoring masked values. 41 | - **IQR (Interquartile Range):** difference between the 75th and 25th percentile of a dataset, ignoring masked values. 42 | - **LE90 (Linear Error with 90% confidence):** difference between the 95th and 5th percentiles of a dataset, representing the range within which 90% of the data points lie. Ignore masked values. 43 | - **NMAD (Normalized Median Absolute Deviation):** robust measure of variability in the data, less sensitive to outliers compared to standard deviation. Ignore masked values. 44 | - **RMSE (Root Mean Square Error):** commonly used to express the magnitude of errors or variability and can give insight into the spread of the data. Only relevant when the raster represents a difference of two objects. Ignore masked values. 45 | - **Std (Standard deviation):** measures the spread or dispersion of the data around the mean, ignoring masked values. 46 | - **Valid count:** number of finite data points in the array. It counts the non-masked elements. 47 | - **Total count:** total size of the raster. 48 | - **Percentage valid points:** ratio between **Valid count** and **Total count**. 49 | 50 | If an inlier mask is passed: 51 | - **Total inlier count:** number of data points in the inlier mask. 52 | - **Valid inlier count:** number of unmasked data points in the array after applying the inlier mask. 53 | - **Percentage inlier points:** ratio between **Valid inlier count** and **Valid count**. Useful for classification statistics. 54 | - **Percentage valid inlier points:** ratio between **Valid inlier count** and **Total inlier count**. 55 | 56 | Callable functions are supported as well. 57 | 58 | ```{code-cell} ipython3 59 | import geoutils as gu 60 | import numpy as np 61 | 62 | # Instantiate a raster from a filename on disk 63 | filename_rast = gu.examples.get_path("exploradores_aster_dem") 64 | rast = gu.Raster(filename_rast) 65 | rast 66 | ``` 67 | 68 | Get all default statistics: 69 | ```{code-cell} ipython3 70 | rast.get_stats() 71 | ``` 72 | 73 | Get a single statistic (e.g., 'mean') as a float: 74 | ```{code-cell} ipython3 75 | rast.get_stats("mean") 76 | ``` 77 | 78 | Get multiple statistics: 79 | ```{code-cell} ipython3 80 | rast.get_stats(["mean", "max", "std"]) 81 | ``` 82 | 83 | Using a custom callable statistic: 84 | ```{code-cell} ipython3 85 | def custom_stat(data): 86 | return np.nansum(data > 100) # Count the number of pixels above 100 87 | rast.get_stats(custom_stat) 88 | ``` 89 | 90 | Passing an inlier mask: 91 | ```{code-cell} ipython3 92 | inlier_mask = rast > 1500 93 | rast.get_stats(inlier_mask=inlier_mask) 94 | ``` 95 | 96 | ## Subsampling 97 | 98 | The {func}`~geoutils.Raster.subsample` method allows to efficiently extract a valid random subsample from a raster or a point cloud. It can conveniently 99 | return the output as a point cloud, or as an array. 100 | 101 | The subsample size can be defined either as a fraction of valid values (floating value strictly between 0 and 1), or as a number of samples (integer value 102 | above 1). 103 | 104 | ```{code-cell} ipython3 105 | # Subsample 10% of the raster valid values 106 | rast.subsample(subsample=0.1) 107 | ``` 108 | -------------------------------------------------------------------------------- /tests/test_raster/test_tiling.py: -------------------------------------------------------------------------------- 1 | """Test tiling tools for arrays and rasters.""" 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | import geoutils as gu 7 | from geoutils import examples 8 | from geoutils.raster.tiling import _generate_tiling_grid 9 | 10 | 11 | class TestTiling: 12 | 13 | landsat_b4_path = examples.get_path_test("everest_landsat_b4") 14 | 15 | def test_subdivide_array(self) -> None: 16 | # Import optional scikit-image or skip test 17 | pytest.importorskip("skimage") 18 | 19 | test_shape = (6, 4) 20 | test_count = 4 21 | subdivision_grid = gu.raster.subdivide_array(test_shape, test_count) 22 | 23 | assert subdivision_grid.shape == test_shape 24 | assert np.unique(subdivision_grid).size == test_count 25 | 26 | assert np.unique(gu.raster.subdivide_array((3, 3), 3)).size == 3 27 | 28 | with pytest.raises(ValueError, match=r"Expected a 2D shape, got 1D shape.*"): 29 | gu.raster.subdivide_array((5,), 2) 30 | 31 | with pytest.raises(ValueError, match=r"Shape.*smaller than.*"): 32 | gu.raster.subdivide_array((5, 2), 15) 33 | 34 | @pytest.mark.parametrize("overlap", [0, 5]) # type: ignore 35 | def test_tiling(self, overlap: int) -> None: 36 | 37 | # Test with mock data 38 | tiling_grid_mock = _generate_tiling_grid(0, 0, 100, 100, 50, 50, overlap) 39 | if overlap == 0: 40 | expected_tiling = np.array([[[0, 50, 0, 50], [0, 50, 50, 100]], [[50, 100, 0, 50], [50, 100, 50, 100]]]) 41 | assert np.array_equal(tiling_grid_mock, expected_tiling) 42 | elif overlap == 5: 43 | expected_tiling = np.array( 44 | [ 45 | [[0, 55, 0, 55], [0, 55, 45, 100]], 46 | [[45, 100, 0, 55], [45, 100, 45, 100]], 47 | ] 48 | ) 49 | assert np.array_equal(tiling_grid_mock, expected_tiling) 50 | 51 | tiling_grid_mock = _generate_tiling_grid(0, 0, 55, 55, 50, 50, overlap) 52 | if overlap == 0: 53 | expected_tiling = np.array([[[0, 50, 0, 50], [0, 50, 50, 55]], [[50, 55, 0, 50], [50, 55, 50, 55]]]) 54 | assert np.array_equal(tiling_grid_mock, expected_tiling) 55 | elif overlap == 5: 56 | expected_tiling = np.array([[[0, 55, 0, 55]]]) 57 | assert np.array_equal(tiling_grid_mock, expected_tiling) 58 | 59 | # Test with real data 60 | img = gu.Raster(self.landsat_b4_path) 61 | 62 | # Define tiling parameters 63 | row_split, col_split = 100, 100 64 | row_max, col_max = img.shape 65 | 66 | # Generate the tiling grid 67 | tiling_grid = _generate_tiling_grid(0, 0, row_max, col_max, row_split, col_split, overlap) 68 | 69 | # Calculate expected number of tiles 70 | nb_row_tiles = np.ceil(row_max / row_split).astype(int) 71 | nb_col_tiles = np.ceil(col_max / col_split).astype(int) 72 | 73 | if 0 < col_max % col_split <= overlap: 74 | nb_col_tiles = max(nb_col_tiles - 1, 1) 75 | if 0 < row_max % row_split <= overlap: 76 | nb_row_tiles = max(nb_row_tiles - 1, 1) 77 | 78 | # Check that the tiling grid has the expected shape 79 | assert tiling_grid.shape == (nb_row_tiles, nb_col_tiles, 4) 80 | 81 | # Check the boundaries of the first and last tile 82 | assert np.array_equal( 83 | tiling_grid[0, 0], 84 | np.array( 85 | [ 86 | 0, 87 | min(row_split + overlap, row_max), 88 | 0, 89 | min(col_split + overlap, col_max), 90 | ] 91 | ), 92 | ) 93 | assert np.array_equal( 94 | tiling_grid[-1, -1], 95 | np.array( 96 | [ 97 | (nb_row_tiles - 1) * row_split - overlap, 98 | row_max, 99 | (nb_col_tiles - 1) * col_split - overlap, 100 | col_max, 101 | ] 102 | ), 103 | ) 104 | 105 | # Check if overlap is consistent between tiles 106 | for row in range(nb_row_tiles - 1): 107 | assert tiling_grid[row + 1, 0, 0] == tiling_grid[row, 0, 1] - 2 * overlap 108 | 109 | for col in range(nb_col_tiles - 1): 110 | assert tiling_grid[0, col + 1, 2] == tiling_grid[0, col, 3] - 2 * overlap 111 | 112 | def test_tiling_overlap_errors(self) -> None: 113 | with pytest.raises(ValueError): 114 | _generate_tiling_grid(0, 0, 100, 100, 50, 50, -1) 115 | with pytest.raises(TypeError): 116 | _generate_tiling_grid(0, 0, 100, 100, 50, 50, 0.5) # type: ignore 117 | -------------------------------------------------------------------------------- /.github/scripts/generate_pip_deps_from_conda.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | (Copied from pandas: https://github.com/pandas-dev/pandas/blob/main/scripts/generate_pip_deps_from_conda.py) 4 | Convert the conda environment.yml to the pip requirements-dev.txt, 5 | or check that they have the same packages (for the CI) 6 | 7 | Usage: 8 | 9 | Generate `requirements-dev.txt` 10 | $ python scripts/generate_pip_deps_from_conda.py 11 | 12 | Compare and fail (exit status != 0) if `requirements-dev.txt` has not been 13 | generated with this script: 14 | $ python scripts/generate_pip_deps_from_conda.py --compare 15 | """ 16 | import argparse 17 | import pathlib 18 | import re 19 | import sys 20 | 21 | if sys.version_info >= (3, 11): 22 | import tomllib 23 | else: 24 | import tomli as tomllib 25 | import yaml 26 | 27 | EXCLUDE = {"python"} 28 | REMAP_VERSION = {"tzdata": "2022.1"} 29 | RENAME = {} 30 | 31 | 32 | def conda_package_to_pip(package: str): 33 | """ 34 | Convert a conda package to its pip equivalent. 35 | 36 | In most cases they are the same, those are the exceptions: 37 | - Packages that should be excluded (in `EXCLUDE`) 38 | - Packages that should be renamed (in `RENAME`) 39 | - A package requiring a specific version, in conda is defined with a single 40 | equal (e.g. ``pandas=1.0``) and in pip with two (e.g. ``pandas==1.0``) 41 | """ 42 | package = re.sub("(?<=[^<>])=", "==", package).strip() 43 | print(package) 44 | 45 | for compare in ("<=", ">=", "=="): 46 | if compare in package: 47 | pkg, version = package.split(compare) 48 | if pkg in EXCLUDE: 49 | return 50 | if pkg in REMAP_VERSION: 51 | return "".join((pkg, compare, REMAP_VERSION[pkg])) 52 | if pkg in RENAME: 53 | return "".join((RENAME[pkg], compare, version)) 54 | 55 | if package in EXCLUDE: 56 | return 57 | 58 | if package in RENAME: 59 | return RENAME[package] 60 | 61 | return package 62 | 63 | 64 | def generate_pip_from_conda(conda_path: pathlib.Path, pip_path: pathlib.Path, compare: bool = False) -> bool: 65 | """ 66 | Generate the pip dependencies file from the conda file, or compare that 67 | they are synchronized (``compare=True``). 68 | 69 | Parameters 70 | ---------- 71 | conda_path : pathlib.Path 72 | Path to the conda file with dependencies (e.g. `environment.yml`). 73 | pip_path : pathlib.Path 74 | Path to the pip file with dependencies (e.g. `requirements-dev.txt`). 75 | compare : bool, default False 76 | Whether to generate the pip file (``False``) or to compare if the 77 | pip file has been generated with this script and the last version 78 | of the conda file (``True``). 79 | 80 | Returns 81 | ------- 82 | bool 83 | True if the comparison fails, False otherwise 84 | """ 85 | with conda_path.open() as file: 86 | deps = yaml.safe_load(file)["dependencies"] 87 | 88 | pip_deps = [] 89 | for dep in deps: 90 | if isinstance(dep, str): 91 | conda_dep = conda_package_to_pip(dep) 92 | if conda_dep: 93 | pip_deps.append(conda_dep) 94 | elif isinstance(dep, dict) and len(dep) == 1 and "pip" in dep: 95 | pip_deps.extend(dep["pip"]) 96 | else: 97 | raise ValueError(f"Unexpected dependency {dep}") 98 | 99 | header = ( 100 | f"# This file is auto-generated from {conda_path.name}, do not modify.\n" 101 | "# See that file for comments about the need/usage of each dependency.\n\n" 102 | ) 103 | pip_content = header + "\n".join(pip_deps) + "\n" 104 | 105 | # Add setuptools to requirements-dev.txt 106 | 107 | # with open(pathlib.Path(conda_path.parent, "pyproject.toml"), "rb") as fd: 108 | # meta = tomllib.load(fd) 109 | # for requirement in meta["build-system"]["requires"]: 110 | # if "setuptools" in requirement: 111 | # pip_content += requirement 112 | # pip_content += "\n" 113 | 114 | if compare: 115 | with pip_path.open() as file: 116 | return pip_content != file.read() 117 | 118 | with pip_path.open("w") as file: 119 | file.write(pip_content) 120 | return False 121 | 122 | 123 | if __name__ == "__main__": 124 | argparser = argparse.ArgumentParser(description="convert (or compare) conda file to pip") 125 | argparser.add_argument( 126 | "--compare", 127 | action="store_true", 128 | help="compare whether the two files are equivalent", 129 | ) 130 | args = argparser.parse_args() 131 | 132 | conda_fname = "environment.yml" 133 | pip_fname = "requirements.txt" 134 | repo_path = pathlib.Path(__file__).parent.parent.parent.absolute() 135 | res = generate_pip_from_conda( 136 | pathlib.Path(repo_path, conda_fname), 137 | pathlib.Path(repo_path, pip_fname), 138 | compare=args.compare, 139 | ) 140 | if res: 141 | msg = f"`{pip_fname}` has to be generated with `{__file__}` after " f"`{conda_fname}` is modified.\n" 142 | sys.stderr.write(msg) 143 | sys.exit(res) 144 | -------------------------------------------------------------------------------- /geoutils/interface/distance.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Functionalities related to distance operations.""" 20 | 21 | from __future__ import annotations 22 | 23 | import warnings 24 | from typing import Literal 25 | 26 | import geopandas as gpd 27 | import numpy as np 28 | from scipy.ndimage import distance_transform_edt 29 | 30 | import geoutils as gu 31 | from geoutils._typing import NDArrayNum 32 | 33 | 34 | def _proximity_from_vector_or_raster( 35 | raster: gu.Raster, 36 | vector: gu.Vector | None = None, 37 | target_values: list[float] | None = None, 38 | geometry_type: str = "boundary", 39 | in_or_out: Literal["in"] | Literal["out"] | Literal["both"] = "both", 40 | distance_unit: Literal["pixel"] | Literal["georeferenced"] = "georeferenced", 41 | ) -> NDArrayNum: 42 | """ 43 | (This function is defined here as mostly raster-based, but used in a class method for both Raster and Vector) 44 | Proximity to a Raster's target values if no Vector is provided, otherwise to a Vector's geometry type 45 | rasterized on the Raster. 46 | 47 | :param raster: Raster to burn the proximity grid on. 48 | :param vector: Vector for which to compute the proximity to geometry, 49 | if not provided computed on the Raster target pixels. 50 | :param target_values: (Only with a Raster) List of target values to use for the proximity, 51 | defaults to all non-zero values. 52 | :param geometry_type: (Only with a Vector) Type of geometry to use for the proximity, defaults to 'boundary'. 53 | :param in_or_out: (Only with a Vector) Compute proximity only 'in' or 'out'-side the geometry, or 'both'. 54 | :param distance_unit: Distance unit, either 'georeferenced' or 'pixel'. 55 | """ 56 | 57 | # 1/ First, if there is a vector input, we rasterize the geometry type 58 | # (works with .boundary that is a LineString (.exterior exists, but is a LinearRing) 59 | if vector is not None: 60 | 61 | # TODO: Only when using centroid... Maybe we should leave this operation to the user anyway? 62 | warnings.filterwarnings("ignore", message="Geometry is in a geographic CRS.*") 63 | 64 | # We create a geodataframe with the geometry type 65 | boundary_shp = gpd.GeoDataFrame(geometry=vector.ds.__getattr__(geometry_type), crs=vector.crs) 66 | # We mask the pixels that make up the geometry type 67 | mask_boundary = gu.Vector(boundary_shp).create_mask(raster, as_array=True) 68 | 69 | else: 70 | # Get raster array 71 | raster_arr = raster.get_nanarray() 72 | 73 | # If input is a mask, target is implicit, and array needs to be converted to uint8 74 | if target_values is None and raster.is_mask: 75 | target_values = [1] 76 | raster_arr = raster_arr.astype("uint8") 77 | 78 | # We mask target pixels 79 | if target_values is not None: 80 | mask_boundary = np.logical_or.reduce([raster_arr == target_val for target_val in target_values]) 81 | # Otherwise, all non-zero values are considered targets 82 | else: 83 | mask_boundary = raster_arr.astype(bool) 84 | 85 | # 2/ Now, we compute the distance matrix relative to the masked geometry type 86 | if distance_unit.lower() == "georeferenced": 87 | sampling: int | tuple[float | int, float | int] = raster.res 88 | elif distance_unit.lower() == "pixel": 89 | sampling = 1 90 | else: 91 | raise ValueError('Distance unit must be either "georeferenced" or "pixel".') 92 | 93 | # If not all pixels are targets, then we compute the distance 94 | non_targets = np.count_nonzero(mask_boundary) 95 | if non_targets > 0: 96 | proximity = distance_transform_edt(~mask_boundary, sampling=sampling) 97 | # Otherwise, pass an array full of nodata 98 | else: 99 | proximity = np.ones(np.shape(mask_boundary)) * np.nan 100 | 101 | # 3/ If there was a vector input, apply the in_and_out argument to optionally mask inside/outside 102 | if vector is not None: 103 | if in_or_out == "both": 104 | pass 105 | elif in_or_out in ["in", "out"]: 106 | mask_polygon = gu.Vector(vector.ds).create_mask(raster, as_array=True) 107 | if in_or_out == "in": 108 | proximity[~mask_polygon] = 0 109 | else: 110 | proximity[mask_polygon] = 0 111 | else: 112 | raise ValueError('The type of proximity must be one of "in", "out" or "both".') 113 | 114 | return proximity 115 | -------------------------------------------------------------------------------- /doc/source/core_py_ops.md: -------------------------------------------------------------------------------- 1 | --- 2 | file_format: mystnb 3 | jupytext: 4 | formats: md:myst 5 | text_representation: 6 | extension: .md 7 | format_name: myst 8 | kernelspec: 9 | display_name: geoutils-env 10 | language: python 11 | name: geoutils 12 | --- 13 | 14 | (core-py-ops)= 15 | # Support of pythonic operators 16 | 17 | GeoUtils integrates pythonic operators for shorter, more intuitive code, and to perform arithmetic and logical operations consistently. 18 | 19 | These operators work on {class}`Rasters` much as they would on {class}`ndarrays`, with some more details. 20 | 21 | ## Arithmetic of {class}`~geoutils.Raster` classes 22 | 23 | Arithmetic operators ({func}`+`, {func}`-`, {func}`/`, {func}`//`, {func}`*`, 24 | {func}`**`, {func}`%`) can be used on a {class}`~geoutils.Raster` in combination with any other {class}`~geoutils.Raster`, 25 | {class}`~numpy.ndarray` or number. 26 | 27 | For an operation with another {class}`~geoutils.Raster`, the georeferencing ({attr}`~geoutils.Raster.crs` and {attr}`~geoutils.Raster.transform`) must match. 28 | For another {class}`~numpy.ndarray`, the {attr}`~geoutils.Raster.shape` must match. The operation always returns a {class}`~geoutils.Raster`. 29 | 30 | ```{code-cell} ipython3 31 | import geoutils as gu 32 | import rasterio as rio 33 | import pyproj 34 | import numpy as np 35 | 36 | # Create a random 3 x 3 masked array 37 | np.random.seed(42) 38 | arr = np.random.randint(0, 255, size=(3, 3), dtype="uint8") 39 | mask = np.random.randint(0, 2, size=(3, 3), dtype="bool") 40 | ma = np.ma.masked_array(data=arr, mask=mask) 41 | 42 | # Create an example raster 43 | rast = gu.Raster.from_array( 44 | data = ma, 45 | transform = rio.transform.from_bounds(0, 0, 1, 1, 3, 3), 46 | crs = pyproj.CRS.from_epsg(4326), 47 | nodata = 255 48 | ) 49 | 50 | rast 51 | ``` 52 | 53 | ```{code-cell} ipython3 54 | # Arithmetic with a number 55 | rast + 1 56 | ``` 57 | 58 | ```{code-cell} ipython3 59 | # Arithmetic with an array 60 | rast / arr 61 | 62 | ``` 63 | ```{code-cell} ipython3 64 | # Arithmetic with a raster 65 | rast - (rast**0.5) 66 | ``` 67 | 68 | If an unmasked {class}`~numpy.ndarray` is passed, it will internally be cast into a {class}`~numpy.ma.MaskedArray` to respect the propagation of 69 | {class}`~geoutils.Raster.nodata` values. Additionally, the {attr}`~geoutils.Raster.dtype` are also reconciled as they would for {class}`~numpy.ndarray`, 70 | following [standard NumPy coercion rules](https://numpy.org/doc/stable/reference/generated/numpy.find_common_type.html). 71 | 72 | ## Logical comparisons cast to a raster mask 73 | 74 | Logical comparison operators ({func}`==`, {func}` != `, {func}`>=`, {func}`>`, {func}`<=`, 75 | {func}`<`) can be used on a {class}`~geoutils.Raster`, also in combination with any other {class}`~geoutils.Raster`, {class}`~numpy.ndarray` or 76 | number. 77 | 78 | Those operation always return a raster mask i.e. a {class}`~geoutils.Raster` with a boolean {class}`~numpy.ma.MaskedArray` as {class}`~geoutils.Raster.data`. 79 | 80 | ```{code-cell} ipython3 81 | # Logical comparison with a number 82 | mask = rast > 100 83 | mask 84 | ``` 85 | 86 | ```{note} 87 | A boolean {class}`~geoutils.Raster`'s {attr}`~geoutils.Raster.data` remains a {class}`~numpy.ma.MaskedArray`. Therefore, it still maps invalid values 88 | through its {attr}`~numpy.ma.MaskedArray.mask`, but has no associated {attr}`~geoutils.Raster.nodata`. 89 | ``` 90 | 91 | ## Logical bitwise operations on raster masks 92 | 93 | Logical bitwise operators ({func}`~ `, {func}`& `, {func}`| `, {func}`^ `) can be used to 94 | combine a boolean {class}`~geoutils.Raster` with another boolean {class}`~geoutils.Raster`, and always output a boolean {class}`~geoutils.Raster`. 95 | 96 | ```{code-cell} ipython3 97 | # Logical bitwise operation between masks 98 | mask = (rast > 100) & ((rast % 2) == 0) 99 | mask 100 | ``` 101 | 102 | (py-ops-indexing)= 103 | 104 | ## Indexing a {class}`~geoutils.Raster` with a raster mask 105 | 106 | Finally, indexing and index assignment operations ({func}`[] `, {func}`[]= `) are both supported by 107 | {class}`Rasters`. 108 | 109 | For indexing, they can be passed either a boolean {class}`~geoutils.Raster` with the same georeferencing, or a boolean {class}`~numpy.ndarray` of the same 110 | shape. 111 | For assignment, either a {class}`~geoutils.Raster` with the same georeferencing, or any {class}`~numpy.ndarray` of the same shape is expected. 112 | 113 | When indexing, a flattened {class}`~numpy.ma.MaskedArray` is returned with the indexed values of the boolean {class}`~geoutils.Raster` **excluding those masked 114 | in its {class}`~geoutils.Raster.data`'s {class}`~numpy.ma.MaskedArray` (for instance, nodata values present during a previous logical comparison)**. To bypass this 115 | behaviour, simply index without the mask using {attr}`Raster.data.data`. 116 | 117 | ```{code-cell} ipython3 118 | # Indexing the raster with the previous mask 119 | rast[mask] 120 | ``` 121 | -------------------------------------------------------------------------------- /tests/test_profiling.py: -------------------------------------------------------------------------------- 1 | """Test the xdem.profiling functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import glob 6 | import os 7 | import os.path as op 8 | 9 | import pandas as pd 10 | import pytest 11 | 12 | import geoutils as gu 13 | from geoutils import examples 14 | from geoutils.profiler import Profiler 15 | 16 | pytest.importorskip("plotly") # import for CI 17 | 18 | 19 | class TestProfiling: 20 | 21 | # Test that there's no crash when giving profiling configuration 22 | @pytest.mark.parametrize( 23 | "profiling_configuration", 24 | [(False, False, True), (True, False, True), (False, True, True), (True, True, True), (True, True, False)], 25 | ) # type: ignore 26 | @pytest.mark.parametrize("profiling_function", ["load", "get_stats", "subsample", "output_given"]) # type: ignore 27 | def test_profiling_configuration(self, profiling_configuration, profiling_function, tmp_path) -> None: 28 | """ 29 | Test the all combinaisons of profiling with three examples of profiled functions. 30 | """ 31 | s_gr = profiling_configuration[0] 32 | s_rd = profiling_configuration[1] 33 | output_given = profiling_configuration[2] 34 | 35 | Profiler.enable(save_graphs=s_gr, save_raw_data=s_rd) 36 | 37 | dem = gu.Raster(examples.get_path_test("everest_landsat_b4")) 38 | if profiling_function == "get_stats": 39 | dem.get_stats() 40 | if profiling_function == "subsample": 41 | gu.Raster.subsample(dem, 2) 42 | 43 | if output_given: 44 | Profiler.generate_summary(tmp_path) 45 | output_path = tmp_path 46 | else: 47 | os.chdir(tmp_path) 48 | Profiler.generate_summary() 49 | output_path = "output_profiling" 50 | 51 | # if profiling is activate 52 | if s_rd or s_gr: 53 | 54 | # in each case, output dir exist 55 | assert op.isdir(output_path) 56 | 57 | # if save_raw_data: 58 | if s_rd: 59 | # check pickle 60 | assert op.isfile(op.join(output_path, "raw_data.pickle")) 61 | 62 | # check data in pickle 63 | df = pd.read_pickle(op.join(output_path, "raw_data.pickle")) 64 | if profiling_function == "get_stats": 65 | assert len(df) == 3 66 | elif profiling_function == "subsample": 67 | assert len(df) == 2 68 | else: 69 | assert len(df) == 1 70 | 71 | else: 72 | assert not op.isfile(op.join(output_path, "raw_data.pickle")) 73 | 74 | # if save_graphs: 75 | if s_gr: 76 | # check if all output graphs (time_graph + mem graph/profiled function called) 77 | # are generated 78 | assert op.isfile(op.join(output_path, "time_graph.html")) 79 | assert op.isfile(op.join(output_path, "memory_geoutils.raster.raster.__init__.html")) 80 | if profiling_function == "get_stats": 81 | assert op.isfile(op.join(output_path, "memory_geoutils.stats.stats._statistics.html")) 82 | assert op.isfile(op.join(output_path, "memory_geoutils.raster.raster.get_stats.html")) 83 | elif profiling_function == "sampling": 84 | assert op.isfile(op.join(output_path, "memory_geoutils.raster.raster.subsample.html")) 85 | else: 86 | assert not len(glob.glob(op.join(output_path, "*.html"))) 87 | 88 | else: 89 | # if profiling is deactivated : nothing generated in output dir 90 | assert not len(glob.glob(op.join(output_path, "*"))) 91 | 92 | def test_profiling_functions_management(self) -> None: 93 | """ 94 | Test the management of profiling functions information. 95 | """ 96 | Profiler.enable(save_graphs=False, save_raw_data=True) 97 | 98 | assert len(Profiler.get_profiling_info()) == 0 99 | gu.Raster(examples.get_path_test("everest_landsat_b4")) 100 | 101 | assert len(Profiler.get_profiling_info()) == 1 102 | assert len(Profiler.get_profiling_info(function_name="geoutils.raster.raster.__init__")) == 1 103 | assert len(Profiler.get_profiling_info(function_name="geoutils.stats.stats.get_stats")) == 0 104 | assert len(Profiler.get_profiling_info(function_name="no_name")) == 0 105 | 106 | Profiler.reset() 107 | assert len(Profiler.get_profiling_info()) == 0 108 | 109 | def test_selections_functions(self) -> None: 110 | """ 111 | Test the selection of functions to profile (all or by theirs names). 112 | """ 113 | Profiler.enable(save_graphs=False, save_raw_data=True) 114 | Profiler.selection_functions(["geoutils.stats.stats._statistics"]) 115 | dem = gu.Raster(examples.get_path_test("everest_landsat_b4")) 116 | 117 | dem.get_stats() 118 | assert len(Profiler.get_profiling_info()) == 1 119 | 120 | Profiler.selection_functions(["geoutils.raster.raster.__init__"]) 121 | dem.get_stats() 122 | assert len(Profiler.get_profiling_info()) == 1 123 | 124 | Profiler.reset_selection_functions() 125 | dem.get_stats() 126 | assert len(Profiler.get_profiling_info()) == 3 127 | -------------------------------------------------------------------------------- /geoutils/interface/gridding.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Functionalities for gridding points (point cloud to raster).""" 20 | 21 | import warnings 22 | from typing import Literal 23 | 24 | import affine 25 | import geopandas as gpd 26 | import numpy as np 27 | import rasterio as rio 28 | from scipy.interpolate import griddata 29 | 30 | from geoutils._typing import NDArrayNum 31 | 32 | 33 | def _grid_pointcloud( 34 | pc: gpd.GeoDataFrame, 35 | grid_coords: tuple[NDArrayNum, NDArrayNum] = None, 36 | data_column_name: str | None = None, 37 | resampling: Literal["nearest", "linear", "cubic"] = "linear", 38 | dist_nodata_pixel: float = 1.0, 39 | ) -> tuple[NDArrayNum, affine.Affine]: 40 | """ 41 | Grid point cloud (possibly irregular coordinates) to raster (regular grid) using delaunay triangles interpolation. 42 | 43 | Based on scipy.interpolate.griddata combined to a nearest point search to replace values of grid cells further than 44 | a certain distance (in number of pixels) by nodata values (as griddata interpolates all values in convex hull, no 45 | matter the distance). 46 | 47 | :param pc: Point cloud. 48 | :param grid_coords: Regular raster grid coordinates in X and Y (i.e. equally spaced, independently for each axis). 49 | :param data_column_name: Name of data column for point cloud (if 2D point geometries are used). 50 | :param resampling: Resampling method within delauney triangles (defaults to linear). 51 | :param dist_nodata_pixel: Distance from the point cloud after which grid cells are filled by nodata values, 52 | expressed in number of pixels. 53 | """ 54 | 55 | # Input checks 56 | if ( 57 | not isinstance(grid_coords, tuple) 58 | or not (isinstance(grid_coords[0], np.ndarray) and grid_coords[0].ndim == 1) 59 | or not (isinstance(grid_coords[1], np.ndarray) and grid_coords[1].ndim == 1) 60 | ): 61 | raise TypeError("Input grid coordinates must be 1D arrays.") 62 | 63 | diff_x = np.diff(grid_coords[0]) 64 | diff_y = np.diff(grid_coords[1]) 65 | 66 | if not all(diff_x == diff_x[0]) and all(diff_y == diff_y[0]): 67 | raise ValueError("Grid coordinates must be regular (equally spaced, independently along X and Y).") 68 | 69 | # 1/ Interpolate irregular point cloud on a regular grid 70 | 71 | # Get meshgrid coordinates 72 | xx, yy = np.meshgrid(grid_coords[0], grid_coords[1]) 73 | 74 | # Use griddata on all points 75 | aligned_dem = griddata( 76 | points=(pc.geometry.x.values, pc.geometry.y.values), 77 | values=pc[data_column_name].values if data_column_name is not None else pc.geometry.z.values, 78 | xi=(xx, yy), 79 | method=resampling, 80 | rescale=True, # Rescale inputs to unit cube to avoid precision issues 81 | ) 82 | 83 | # 2/ Identify which grid points are more than X pixels away from the point cloud, and convert to NaNs 84 | # (otherwise all grid points in the convex hull of the irregular triangulation are filled, no matter the distance) 85 | 86 | # Get the nearest point for each grid point 87 | grid_pc = gpd.GeoDataFrame( 88 | data={"placeholder": np.ones(len(xx.ravel()))}, 89 | geometry=gpd.points_from_xy(x=xx.ravel(), y=yy.ravel()), 90 | crs=pc.crs, 91 | ) 92 | with warnings.catch_warnings(): 93 | warnings.filterwarnings("ignore", category=UserWarning, message="Geometry is in a geographic CRS.*") 94 | near = gpd.sjoin_nearest(grid_pc, pc) 95 | # In case there are several points at the same distance, it doesn't matter which one is used to compute the 96 | # distance, so we keep the first index of closest point 97 | index_right = near.groupby(by=near.index)["index_right"].min() 98 | 99 | # Compute distance between points as a function of the pixel sizes in X and Y 100 | res_x = np.abs(grid_coords[0][1] - grid_coords[0][0]) 101 | res_y = np.abs(grid_coords[1][1] - grid_coords[1][0]) 102 | dist = np.sqrt( 103 | ((pc.geometry.x.values[index_right] - grid_pc.geometry.x.values) / res_x) ** 2 104 | + ((pc.geometry.y.values[index_right] - grid_pc.geometry.y.values) / res_y) ** 2 105 | ) 106 | 107 | # Replace all points further away than the distance of nodata by NaNs 108 | aligned_dem[dist.reshape(aligned_dem.shape) > dist_nodata_pixel] = np.nan 109 | 110 | # Flip Y axis of grid 111 | aligned_dem = np.flip(aligned_dem, axis=0) 112 | 113 | # 3/ Derive output transform from input grid 114 | transform_from_coords = rio.transform.from_origin(min(grid_coords[0]), max(grid_coords[1]), res_x, res_y) 115 | 116 | return aligned_dem, transform_from_coords 117 | -------------------------------------------------------------------------------- /geoutils/examples.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 GeoUtils developers 2 | # 3 | # This file is part of the GeoUtils project: 4 | # https://github.com/glaciohack/geoutils 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | 19 | """Utility functions to download and find example data.""" 20 | 21 | import os 22 | import shutil 23 | import tarfile 24 | import tempfile 25 | import urllib.request 26 | 27 | # Define the location of the data in the example directory 28 | _EXAMPLES_DIRECTORY = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "examples/data")) 29 | 30 | # Absolute filepaths to the example files. 31 | _FILEPATHS_DATA = { 32 | "everest_landsat_rgb": os.path.join(_EXAMPLES_DIRECTORY, "Everest_Landsat", "LE71400412000304SGS00_RGB.tif"), 33 | "everest_landsat_b4": os.path.join(_EXAMPLES_DIRECTORY, "Everest_Landsat", "LE71400412000304SGS00_B4.tif"), 34 | "everest_landsat_b4_cropped": os.path.join( 35 | _EXAMPLES_DIRECTORY, "Everest_Landsat", "LE71400412000304SGS00_B4_cropped.tif" 36 | ), 37 | "everest_rgi_outlines": os.path.join(_EXAMPLES_DIRECTORY, "Everest_Landsat", "15_rgi60_glacier_outlines.gpkg"), 38 | "exploradores_aster_dem": os.path.join( 39 | _EXAMPLES_DIRECTORY, "Exploradores_ASTER", "AST_L1A_00303182012144228_Z.tif" 40 | ), 41 | "exploradores_rgi_outlines": os.path.join( 42 | _EXAMPLES_DIRECTORY, "Exploradores_ASTER", "17_rgi60_glacier_outlines.gpkg" 43 | ), 44 | "coromandel_lidar": os.path.join(_EXAMPLES_DIRECTORY, "Coromandel_Lidar", "points.laz"), 45 | } 46 | 47 | _FILEPATHS_TEST = { 48 | k: os.path.join( 49 | os.path.dirname(v), 50 | os.path.splitext(os.path.basename(v))[0] + "_test" + os.path.splitext(os.path.basename(v))[1], 51 | ) 52 | for k, v in _FILEPATHS_DATA.items() 53 | } 54 | 55 | available = list(_FILEPATHS_DATA.keys()) 56 | available_test = list(_FILEPATHS_TEST.keys()) 57 | 58 | 59 | def download_examples(overwrite: bool = False) -> None: 60 | """ 61 | Fetch the example files. 62 | 63 | :param overwrite: Do not download the files again if they already exist. 64 | """ 65 | if not overwrite and all(map(os.path.isfile, list(_FILEPATHS_DATA.values()))): 66 | # print("Datasets exist") 67 | return 68 | 69 | # Static commit hash to be bumped every time it needs to be. 70 | commit = "e758274647a8dd2656d73c3026c90cc77cab8a86" 71 | # The URL from which to download the repository 72 | url = f"https://github.com/GlacioHack/geoutils-data/tarball/main#commit={commit}" 73 | 74 | # Create a temporary directory to extract the tarball in. 75 | with tempfile.TemporaryDirectory() as tmp_dir: 76 | tar_path = os.path.join(tmp_dir, "data.tar.gz") 77 | 78 | response = urllib.request.urlopen(url) 79 | # If the response was right, download the tarball to the temporary directory 80 | if response.getcode() == 200: 81 | with open(tar_path, "wb") as outfile: 82 | outfile.write(response.read()) 83 | else: 84 | raise ValueError(f"Example data fetch gave non-200 response: {response.status_code}") 85 | 86 | # Extract the tarball 87 | with tarfile.open(tar_path) as tar: 88 | tar.extractall(tmp_dir) 89 | 90 | # Find the first directory in the temp_dir (should only be one) and construct the example data dir paths. 91 | for dir_name in ["Everest_Landsat", "Exploradores_ASTER", "Coromandel_Lidar"]: 92 | tmp_dir_name = os.path.join( 93 | tmp_dir, 94 | [dirname for dirname in os.listdir(tmp_dir) if os.path.isdir(os.path.join(tmp_dir, dirname))][0], 95 | "data", 96 | dir_name, 97 | ) 98 | 99 | # Copy the temporary extracted data to the example directory. 100 | shutil.copytree(tmp_dir_name, os.path.join(_EXAMPLES_DIRECTORY, dir_name), dirs_exist_ok=True) 101 | 102 | 103 | def get_path(name: str) -> str: 104 | """ 105 | Get path of example data. List of available files can be found in "examples.available". 106 | 107 | :param name: Name of test data. 108 | :return: 109 | """ 110 | if name in list(_FILEPATHS_DATA.keys()): 111 | download_examples() 112 | return _FILEPATHS_DATA[name] 113 | else: 114 | raise ValueError('Data name should be one of "' + '" , "'.join(list(_FILEPATHS_DATA.keys())) + '".') 115 | 116 | 117 | def get_path_test(name: str) -> str: 118 | """ 119 | Get path of test data (reduced size). List of available files can be found in "examples.available". 120 | 121 | :param name: Name of test data. 122 | :return: 123 | """ 124 | if name in list(_FILEPATHS_TEST.keys()): 125 | download_examples() 126 | return _FILEPATHS_TEST[name] 127 | else: 128 | raise ValueError('Data name should be one of "' + '" , "'.join(list(_FILEPATHS_TEST.keys())) + '".') 129 | -------------------------------------------------------------------------------- /tests/test_vector/test_geotransformations_vector.py: -------------------------------------------------------------------------------- 1 | """Tests for geotransformations of vectors.""" 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | 7 | import numpy as np 8 | import pytest 9 | from geopandas.testing import assert_geodataframe_equal, assert_geoseries_equal 10 | 11 | import geoutils as gu 12 | 13 | 14 | class TestGeotransformations: 15 | 16 | landsat_b4_path = gu.examples.get_path_test("everest_landsat_b4") 17 | landsat_b4_crop_path = gu.examples.get_path_test("everest_landsat_b4_cropped") 18 | everest_outlines_path = gu.examples.get_path_test("everest_rgi_outlines") 19 | aster_dem_path = gu.examples.get_path_test("exploradores_aster_dem") 20 | aster_outlines_path = gu.examples.get_path_test("exploradores_rgi_outlines") 21 | 22 | def test_reproject(self) -> None: 23 | """Test that the reproject function works as intended""" 24 | 25 | v0 = gu.Vector(self.aster_outlines_path) 26 | r0 = gu.Raster(self.aster_dem_path) 27 | v1 = gu.Vector(self.everest_outlines_path) 28 | 29 | # First, test with a EPSG integer 30 | v1 = v0.reproject(crs=32617) 31 | assert isinstance(v1, gu.Vector) 32 | assert v1.crs.to_epsg() == 32617 33 | 34 | # Check the inplace behaviour matches the not-inplace one 35 | v2 = v0.copy() 36 | v2.reproject(crs=32617, inplace=True) 37 | v2.vector_equal(v1) 38 | 39 | # Check that the reprojection is the same as with geopandas 40 | gpd1 = v0.ds.to_crs(epsg=32617) 41 | assert_geodataframe_equal(gpd1, v1.ds) 42 | 43 | # Second, with a Raster object 44 | v2 = v0.reproject(r0) 45 | assert v2.crs == r0.crs 46 | 47 | # Third, with a Vector object that has a different CRS 48 | assert v0.crs != v1.crs 49 | v3 = v0.reproject(v1) 50 | assert v3.crs == v1.crs 51 | 52 | # Fourth, check that errors are raised when appropriate 53 | # When no destination CRS is defined, or both dst_crs and dst_ref are passed 54 | with pytest.raises(ValueError, match=re.escape("Either of `ref` or `crs` must be set. Not both.")): 55 | v0.reproject() 56 | v0.reproject(ref=r0, crs=32617) 57 | # If input of wrong type 58 | with pytest.raises(TypeError, match=re.escape("Type of ref must be a raster or vector.")): 59 | v0.reproject(ref=10) # type: ignore 60 | 61 | test_data = [[landsat_b4_path, everest_outlines_path], [aster_dem_path, aster_outlines_path]] 62 | 63 | @pytest.mark.parametrize("data", test_data) # type: ignore 64 | def test_crop(self, data: list[str]) -> None: 65 | # Load data 66 | raster_path, outlines_path = data 67 | rst = gu.Raster(raster_path) 68 | outlines = gu.Vector(outlines_path) 69 | 70 | # Need to reproject to r.crs. Otherwise, crop will work but will be approximate 71 | # Because outlines might be warped in a different crs 72 | outlines.ds = outlines.ds.to_crs(rst.crs) 73 | 74 | # Crop 75 | outlines_new = outlines.copy() 76 | outlines_new.crop(crop_geom=rst, inplace=True) 77 | 78 | # Check default behaviour - crop and return copy 79 | outlines_copy = outlines.crop(crop_geom=rst) 80 | 81 | # Crop by passing bounds 82 | outlines_new_bounds = outlines.copy() 83 | outlines_new_bounds.crop(crop_geom=list(rst.bounds), inplace=True) 84 | assert_geodataframe_equal(outlines_new.ds, outlines_new_bounds.ds) 85 | # Check the return-by-copy as well 86 | assert_geodataframe_equal(outlines_copy.ds, outlines_new_bounds.ds) 87 | 88 | # Verify that geometries intersect with raster bound 89 | rst_poly = gu.projtools.bounds2poly(rst.bounds) 90 | intersects_new = [] 91 | for poly in outlines_new.ds.geometry: 92 | intersects_new.append(poly.intersects(rst_poly)) 93 | 94 | assert np.all(intersects_new) 95 | 96 | # Check that some of the original outlines did not intersect and were removed 97 | intersects_old = [] 98 | for poly in outlines.ds.geometry: 99 | intersects_old.append(poly.intersects(rst_poly)) 100 | 101 | assert np.sum(intersects_old) == np.sum(intersects_new) 102 | 103 | # Check that some features were indeed removed if any geometry didn't intersect the raster bounds 104 | if any(~np.array(intersects_old)): 105 | assert np.sum(~np.array(intersects_old)) > 0 106 | 107 | # Check that error is raised when cropGeom argument is invalid 108 | with pytest.raises(TypeError, match="Crop geometry must be a Raster, Vector, or list of coordinates."): 109 | outlines.crop(1, inplace=True) # type: ignore 110 | 111 | def test_translate(self) -> None: 112 | 113 | vector = gu.Vector(self.everest_outlines_path) 114 | 115 | # Check default behaviour is not inplace 116 | vector_shifted = vector.translate(xoff=2.5, yoff=5.7) 117 | assert isinstance(vector_shifted, gu.Vector) 118 | assert_geoseries_equal(vector_shifted.geometry, vector.geometry.translate(xoff=2.5, yoff=5.7)) 119 | 120 | # Check inplace behaviour works correctly 121 | vector2 = vector.copy() 122 | output = vector2.translate(xoff=2.5, yoff=5.7, inplace=True) 123 | assert output is None 124 | assert_geoseries_equal(vector2.geometry, vector_shifted.geometry) 125 | --------------------------------------------------------------------------------