From 931b3e00564ea845ca6292572d9d7d2c76dc087f Mon Sep 17 00:00:00 2001 From: vschaffn Date: Wed, 30 Oct 2024 14:04:02 +0100 Subject: [PATCH 1/7] feat: add cli configuration, adapt environment and setup --- NOTICE | 5 ++++ dev-environment.yml | 1 + environment.yml | 1 + requirements.txt | 1 + setup.cfg | 4 +++ setup.py | 15 +++++++++-- xdem/xdem_cli.py | 61 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 xdem/xdem_cli.py diff --git a/NOTICE b/NOTICE index 0a862f21..f92dabf6 100644 --- a/NOTICE +++ b/NOTICE @@ -110,6 +110,11 @@ Copyright (c) 2014-2023, Alexander Fabisch, and pytransform3d contributors. Website: https://github.com/rock-learning/pytransform3d License: BSD 3-Clause. +argcomplete: Python and tab completion, better together. +Copyright (c) 2012-2023, Andrey Kislyuk and argcomplete contributors. +Website: https://github.com/kislyuk/argcomplete +License: Apache v2.0. + tqdm: A fast, extensible progress bar for Python an CLI applications. Copyright (c) MIT 2013 Noam Yorav-Raphael, original author. Copyright (c) MPL-2.0 2015-2024 Casper da Costa-Luis. diff --git a/dev-environment.yml b/dev-environment.yml index 73eddcd5..e1fb4914 100644 --- a/dev-environment.yml +++ b/dev-environment.yml @@ -18,6 +18,7 @@ dependencies: - pandas - pyogrio - shapely + - argcomplete # Development-specific, to mirror manually in setup.cfg [options.extras_require]. - pip diff --git a/environment.yml b/environment.yml index 3fe62ae4..40a336d2 100644 --- a/environment.yml +++ b/environment.yml @@ -19,6 +19,7 @@ dependencies: - pandas - pyogrio - shapely + - argcomplete # To run CI against latest GeoUtils # - pip: diff --git a/requirements.txt b/requirements.txt index c293c1be..f650da56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,4 @@ affine pandas pyogrio shapely +argcomplete diff --git a/setup.cfg b/setup.cfg index 8adfa7ae..22b3381a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -79,3 +79,7 @@ dev = %(test)s all = %(dev)s + +[options.entry_points] +console_scripts = + xdem = xdem_cli:main diff --git a/setup.py b/setup.py index 7f7e78e1..b88f284e 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,16 @@ """This file now only serves for backward-compatibility for routines explicitly calling python setup.py""" -from setuptools import setup +from setuptools import find_packages, setup -setup() + +setup( + name="xdem", + use_scm_version=True, # Enable versioning with setuptools_scm + setup_requires=["setuptools_scm"], # Ensure setuptools_scm is used to determine the version + packages=find_packages(), + entry_points={ + "console_scripts": [ + "xdem = xdem.xdem_cli:main", + ], + }, +) diff --git a/xdem/xdem_cli.py b/xdem/xdem_cli.py new file mode 100644 index 00000000..89e96d63 --- /dev/null +++ b/xdem/xdem_cli.py @@ -0,0 +1,61 @@ +import argparse +from argparse import ArgumentParser + +import argcomplete + +import xdem + + +def get_parser() -> ArgumentParser: + """ + ArgumentParser for xdem + + :return: parser + """ + parser = argparse.ArgumentParser( + description="Compare Digital Elevation Models", + fromfile_prefix_chars="@", + ) + + parser.add_argument( + "reference_dem", + help="path to a reference dem", + ) + + parser.add_argument( + "dem_to_be_aligned", + help="path to a second dem", + ) + + parser.add_argument( + "--loglevel", + default="INFO", + choices=("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"), + help="Logger level (default: INFO. Should be one of (DEBUG, INFO, WARNING, ERROR, CRITICAL)", + ) + + parser.add_argument( + "--version", + "-v", + action="version", + version=f"%(prog)s {xdem.__version__}", + ) + + return parser + + +def main() -> None: + """ + Call xDEM's main + """ + parser = get_parser() + argcomplete.autocomplete(parser) + args = parser.parse_args() + try: + xdem.run(args.reference_dem, args.dem_to_be_aligned, args.loglevel) + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + main() From 32a0879fa4fb5f1f9f09487aeab12b7183bc4769 Mon Sep 17 00:00:00 2001 From: vschaffn Date: Wed, 30 Oct 2024 14:07:48 +0100 Subject: [PATCH 2/7] feat: initialise run function for cli --- xdem/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/xdem/__init__.py b/xdem/__init__.py index fc94abc2..2bf750ff 100644 --- a/xdem/__init__.py +++ b/xdem/__init__.py @@ -40,3 +40,10 @@ "virtualenv) and then install it in-place by running: " "pip install -e ." ) + + +def run(reference_dem: str, dem_to_be_aligned: str, verbose: str) -> None: + """ + Function to compare DEMs + """ + print("hello world") From 0d2ca7f2c11152800f69003fe172c2d26a46978a Mon Sep 17 00:00:00 2001 From: vschaffn Date: Wed, 30 Oct 2024 14:08:35 +0100 Subject: [PATCH 3/7] docs: add cli doc --- doc/source/quick_start.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/doc/source/quick_start.md b/doc/source/quick_start.md index da3058bb..e48bc5b4 100644 --- a/doc/source/quick_start.md +++ b/doc/source/quick_start.md @@ -112,6 +112,21 @@ import os os.remove("dh_error.tif") ``` +## Command Line Interface (CLI) + +The xDEM package can be executed from the command line using the `xdem` command. + +### Usage + +```bash +xdem path_ref path_sec [options] +``` + +### Options + +- `--loglevel`: Set the logging level (default: INFO). +- `-v`, `--version`: Show the version of the xDEM package. + (quick-gallery)= ## More examples From 1995d0c203798f8832b5fd95a29769e78877322a Mon Sep 17 00:00:00 2001 From: vschaffn Date: Wed, 30 Oct 2024 14:09:51 +0100 Subject: [PATCH 4/7] test: add a cli test --- tests/test_cli.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..34bbf960 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,30 @@ +"""Function to test the CLI""" + +import subprocess + +import xdem + + +class TestCLI: + # Define paths to the DEM files using xDEM examples + ref_dem_path = xdem.examples.get_path("longyearbyen_ref_dem") + tba_dem_path = xdem.examples.get_path("longyearbyen_tba_dem") + + def test_xdem_cli(self) -> None: + try: + # Run the xDEM CLI command with the reference and secondary DEM files + result = subprocess.run( + ["xdem", self.ref_dem_path, self.tba_dem_path], + capture_output=True, + text=True, + ) + assert "hello world" in result.stdout + assert result.returncode == 0 + + except FileNotFoundError as e: + # In case 'xdem' is not found + raise AssertionError(f"CLI command 'xdem' not found : {e}") + + except Exception as e: + # Any other errors during subprocess run + raise AssertionError(f"An error occurred while running the CLI: {e}") From d053968f7e84797e952637343bc7a06dbe49e780 Mon Sep 17 00:00:00 2001 From: vschaffn Date: Thu, 31 Oct 2024 09:58:03 +0100 Subject: [PATCH 5/7] docs: update CLI documentation for coregistration --- doc/source/quick_start.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/doc/source/quick_start.md b/doc/source/quick_start.md index e48bc5b4..d19f7ee6 100644 --- a/doc/source/quick_start.md +++ b/doc/source/quick_start.md @@ -119,7 +119,7 @@ The xDEM package can be executed from the command line using the `xdem` command. ### Usage ```bash -xdem path_ref path_sec [options] +xdem [options] [command] path_ref path_tba ``` ### Options @@ -127,6 +127,17 @@ xdem path_ref path_sec [options] - `--loglevel`: Set the logging level (default: INFO). - `-v`, `--version`: Show the version of the xDEM package. +### Commands + +- `coregister`: Perform a coregistration between `path_ref` and `path_tba`. + +### Example +```bash +xdem coregister examples/data/Longyearbyen/data/DEM_2009_ref.tif examples/data/Longyearbyen/data/DEM_1990.tif +``` +This will perform a coregistration between the reference DEM (`DEM_2009_ref.tif`) and the DEM to be aligned +(`DEM_1990.tif`), then save the aligned DEM and inlier mask as `aligned_dem.tif` and `inlier_mask.npy`, respectively. + (quick-gallery)= ## More examples From cf5d03187ba0c414f616efb496c27faea9665b7a Mon Sep 17 00:00:00 2001 From: vschaffn Date: Thu, 31 Oct 2024 09:59:40 +0100 Subject: [PATCH 6/7] feat: update cli for coregistration --- xdem/__init__.py | 47 ++++++++++++++++++++++++++++++++++--- xdem/xdem_cli.py | 61 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 87 insertions(+), 21 deletions(-) diff --git a/xdem/__init__.py b/xdem/__init__.py index 2bf750ff..43f41e31 100644 --- a/xdem/__init__.py +++ b/xdem/__init__.py @@ -16,6 +16,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging +import os + +import geoutils + from xdem import ( # noqa coreg, dem, @@ -26,6 +31,7 @@ terrain, volume, ) +from xdem.coreg.workflows import dem_coregistration from xdem.ddem import dDEM # noqa from xdem.dem import DEM # noqa from xdem.demcollection import DEMCollection # noqa @@ -42,8 +48,43 @@ ) -def run(reference_dem: str, dem_to_be_aligned: str, verbose: str) -> None: +def coregister(ref_dem_path: str, tba_dem_path: str) -> None: """ - Function to compare DEMs + Function to compare and coregister Digital Elevation Models (DEMs). + + This function verifies the existence of the provided DEM paths, + loads the reference DEM and the DEM to be aligned, and performs + coregistration. The aligned DEM and an inlier mask are then saved + to disk. + + :param ref_dem_path: Path to the reference DEM file. + :param tba_dem_path: Path to the DEM that needs to be aligned to the reference. + :return: + :raises FileNotFoundError: if the reference DEM or the DEM to be aligned does not exist. """ - print("hello world") + # Verify that both DEM paths exist + if not os.path.exists(ref_dem_path): + raise FileNotFoundError(f"Reference DEM path does not exist: {ref_dem_path}") + if not os.path.exists(tba_dem_path): + raise FileNotFoundError(f"DEM to be aligned path does not exist: {tba_dem_path}") + + logging.info("Loading DEMs: %s, %s", ref_dem_path, tba_dem_path) + + # Load the reference and secondary DEMs + reference_dem, to_be_aligned_dem = geoutils.raster.load_multiple_rasters([ref_dem_path, tba_dem_path]) + + # Execute coregistration + logging.info("Starting coregistration...") + coreg_dem, coreg_method, out_stats, inlier_mask = dem_coregistration( + to_be_aligned_dem, reference_dem, "aligned_dem.tiff" + ) + + # Save outputs + logging.info("Saving aligned DEM and inlier mask...") + inlier_rst = coreg_dem.copy(new_array=inlier_mask) + inlier_rst.save("inlier_mask.tiff") + + # Print the coregistration details + print(coreg_method.info()) + print("Coregistration statistics:\n", out_stats) + logging.info("Coregistration completed") diff --git a/xdem/xdem_cli.py b/xdem/xdem_cli.py index 89e96d63..1415cb5a 100644 --- a/xdem/xdem_cli.py +++ b/xdem/xdem_cli.py @@ -1,4 +1,24 @@ +# Copyright (c) 2024 xDEM developers +# +# This file is part of the xDEM project: +# https://github.com/glaciohack/xdem +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" CLI configuration for xDEM""" import argparse +import logging from argparse import ArgumentParser import argcomplete @@ -12,20 +32,7 @@ def get_parser() -> ArgumentParser: :return: parser """ - parser = argparse.ArgumentParser( - description="Compare Digital Elevation Models", - fromfile_prefix_chars="@", - ) - - parser.add_argument( - "reference_dem", - help="path to a reference dem", - ) - - parser.add_argument( - "dem_to_be_aligned", - help="path to a second dem", - ) + parser = argparse.ArgumentParser(prog="xdem", description="xDEM command-line interface") parser.add_argument( "--loglevel", @@ -41,6 +48,13 @@ def get_parser() -> ArgumentParser: version=f"%(prog)s {xdem.__version__}", ) + subparsers = parser.add_subparsers(title="Subcommands", dest="command") + + # Subcommand for coregistration + coregister_parser = subparsers.add_parser("coregister", help="Coregister two DEMs") + coregister_parser.add_argument("reference_dem", help="path to a reference dem") + coregister_parser.add_argument("dem_to_be_aligned", help="path to a second dem") + return parser @@ -51,10 +65,21 @@ def main() -> None: parser = get_parser() argcomplete.autocomplete(parser) args = parser.parse_args() - try: - xdem.run(args.reference_dem, args.dem_to_be_aligned, args.loglevel) - except Exception as e: - print(f"Error: {e}") + + # Show help if no subcommand is provided + if not args.command: + parser.print_help() + return + + # Set the logging configuration + logging.basicConfig(level=args.loglevel) + + # Handle coregister subcommand + if args.command == "coregister": + try: + xdem.coregister(args.reference_dem, args.dem_to_be_aligned) + except Exception as e: + print(f"Error: {e}") if __name__ == "__main__": From 3940dc179f4fd6bfd1b47419836653ae6583f634 Mon Sep 17 00:00:00 2001 From: vschaffn Date: Thu, 31 Oct 2024 10:02:12 +0100 Subject: [PATCH 7/7] test: update cli test for coregistration --- setup.py | 1 - tests/test_cli.py | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index b88f284e..7f9d3dfc 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,6 @@ from setuptools import find_packages, setup - setup( name="xdem", use_scm_version=True, # Enable versioning with setuptools_scm diff --git a/tests/test_cli.py b/tests/test_cli.py index 34bbf960..88f69488 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,26 +1,55 @@ """Function to test the CLI""" +import os import subprocess +import rasterio + import xdem +from xdem import dem_coregistration class TestCLI: # Define paths to the DEM files using xDEM examples ref_dem_path = xdem.examples.get_path("longyearbyen_ref_dem") tba_dem_path = xdem.examples.get_path("longyearbyen_tba_dem") + aligned_dem_path = "aligned_dem.tiff" + inlier_mask_path = "inlier_mask.tiff" - def test_xdem_cli(self) -> None: + def test_xdem_cli_coreg(self) -> None: try: # Run the xDEM CLI command with the reference and secondary DEM files result = subprocess.run( - ["xdem", self.ref_dem_path, self.tba_dem_path], + ["xdem", "coregister", self.ref_dem_path, self.tba_dem_path], capture_output=True, text=True, ) - assert "hello world" in result.stdout + + # Assert ClI ran successfully assert result.returncode == 0 + # Verify the existence of the output files + assert os.path.exists(self.aligned_dem_path), f"Aligned DEM not found: {self.aligned_dem_path}" + assert os.path.exists(self.inlier_mask_path), f"Inlier mask not found: {self.inlier_mask_path}" + + # Retrieve ground truth + true_coreg_dem, coreg_method, out_stats, true_inlier_mask = dem_coregistration( + xdem.DEM(self.tba_dem_path), xdem.DEM(self.ref_dem_path), self.aligned_dem_path + ) + + # Load elements processed by the xDEM CLI command + aligned_dem = xdem.DEM(self.aligned_dem_path) + with rasterio.open(self.inlier_mask_path) as src: + inlier_mask = src.read(1) + + # Verify match with ground truth + assert aligned_dem == true_coreg_dem, "Aligned DEM does not match the ground truth." + assert inlier_mask.all() == true_inlier_mask.all(), "Inlier mask does not match the ground truth." + + # Erase files + os.remove(self.aligned_dem_path) + os.remove(self.inlier_mask_path) + except FileNotFoundError as e: # In case 'xdem' is not found raise AssertionError(f"CLI command 'xdem' not found : {e}")