diff --git a/.gitignore b/.gitignore index 6380b0dbcba11c8929393ab8d7603d571276e93a..6a8fbea7b1db968e5d15ebe71ceca7a0697a3b77 100644 --- a/.gitignore +++ b/.gitignore @@ -149,7 +149,10 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ + +# VSCode +.vscode # Test artifacts tests/*.tif \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c373802909a4316011c17fda2f5e558b3dd4a3d2..d37b36dfbfe4cca740828336e4a3f0cdaea032fb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,50 +1,59 @@ workflow: rules: - - if: $CI_MERGE_REQUEST_ID # Execute jobs in merge request context - - if: $CI_COMMIT_BRANCH == 'develop' # Execute jobs when a new commit is pushed to develop branch + - if: $CI_PIPELINE_SOURCE == "merge_request_event" || $CI_COMMIT_BRANCH == "develop" -image: $CI_REGISTRY/orfeotoolbox/otb-build-env/otb-ubuntu-native-develop-headless:20.04 +default: + image: $CI_REGISTRY/orfeotoolbox/otb-build-env/otb-ubuntu-native-develop-headless:20.04 stages: - Static Analysis - Documentation - - Test + - Tests -# --------------------------------- Static analysis --------------------------------- +# -------------------------------- Static analysis -------------------------------- -.static_analysis_base: +.static_analysis: stage: Static Analysis + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: + - pyotb/*.py + - tests/*.py tags: - light allow_failure: true -flake8: - extends: .static_analysis_base - script: - - pip install flake8 - - python3 -m flake8 --max-line-length=120 $PWD/pyotb --ignore=F403,E402,F401,W503,W504 - -pylint: - extends: .static_analysis_base +codespell: + extends: .static_analysis + before_script: + - pip install codespell script: - - pip install pylint - - pylint --max-line-length=120 $PWD/pyotb --disable=too-many-nested-blocks,too-many-locals,too-many-statements,too-few-public-methods,too-many-instance-attributes,too-many-arguments,invalid-name,fixme,too-many-return-statements,too-many-lines,too-many-branches,import-outside-toplevel,wrong-import-position,wrong-import-order,import-error,missing-class-docstring + - codespell {pyotb,tests} -codespell: - extends: .static_analysis_base +flake8: + extends: .static_analysis + before_script: + - pip install flake8 script: - - pip install codespell - - codespell --skip="*.png,*.jpg,*git/lfs*" + - flake8 --max-line-length=120 $PWD/pyotb --ignore=F403,E402,F401,W503,W504 pydocstyle: - extends: .static_analysis_base - script: + extends: .static_analysis + before_script: - pip install pydocstyle + script: - pydocstyle $PWD/pyotb --convention=google +pylint: + extends: .static_analysis + before_script: + - pip install pylint + script: + - pylint --max-line-length=120 $PWD/pyotb --disable=too-many-nested-blocks,too-many-locals,too-many-statements,too-few-public-methods,too-many-instance-attributes,too-many-arguments,invalid-name,fixme,too-many-return-statements,too-many-lines,too-many-branches,import-outside-toplevel,wrong-import-position,wrong-import-order,import-error,missing-class-docstring + +# ---------------------------------- Documentation ---------------------------------- -# ------------------------------------------------------- Doc ---------------------------------------------------------- -.doc_base: +.docs: stage: Documentation tags: - light @@ -52,98 +61,70 @@ pydocstyle: - apt-get update && apt-get -y install virtualenv - virtualenv doc_env - source doc_env/bin/activate - - pip install --upgrade pip - - pip install mkdocstrings mkdocstrings[crystal,python] mkdocs-material mkdocs-gen-files mkdocs-section-index mkdocs-literate-nav mkdocs-mermaid2-plugin --upgrade - artifacts: - paths: - - public - - public_test + - pip install -U pip + - pip install -U -r doc/doc_requirements.txt -test: - extends: .doc_base - rules: - - if: $CI_COMMIT_REF_NAME != $CI_DEFAULT_BRANCH +pages_test: + extends: .docs + when: manual script: - mkdocs build --site-dir public_test + artifacts: + paths: + - public_test pages: - extends: .doc_base + extends: .docs rules: - if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH script: - mkdocs build --site-dir public + artifacts: + paths: + - public +# -------------------------------------- Tests -------------------------------------- -# --------------------------------- Test --------------------------------- - -.test_base: - stage: Test +.tests: + stage: Tests + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + changes: + - pyotb/*.py + - tests/*.py tags: - light allow_failure: false variables: OTB_ROOT: /opt/otb LD_LIBRARY_PATH: /opt/otb/lib - PYOTB_LOGGER_LEVEL: DEBUG OTB_LOGGER_LEVEL: INFO - PIPELINE_TEST_INPUT_IMAGE: image.tif + PYOTB_LOGGER_LEVEL: DEBUG + IMAGE_URL: https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif?inline=false + TEST_INPUT_IMAGE: tests/image.tif + artifacts: + reports: + junit: test-*.xml before_script: - - pip install . - - cd tests - - wget https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/raw/develop/Data/Input/SP67_FR_subset_1.tif?inline=false -O $PIPELINE_TEST_INPUT_IMAGE - -basic_tests: - extends: .test_base - script: - - python3 basic_tests.py - -compute_ndvi: - extends: .test_base - allow_failure: true - script: - - python3 ndvi_test.py - -numpy_array: - extends: .test_base - script: - - python3 numpy_array_test.py - -pipeline_test_shape: - extends: .test_base - script: - - python3 pipeline_test.py shape - -pipeline_test_shape_backward: - extends: .test_base - script: - - python3 pipeline_test.py shape backward - -pipeline_test_shape_nointermediate: - extends: .test_base - script: - - python3 pipeline_test.py shape no-intermediate-result - -pipeline_test_shape_backward_nointermediate: - extends: .test_base - script: - - python3 pipeline_test.py shape backward no-intermediate-result + - wget $IMAGE_URL -O $TEST_INPUT_IMAGE + - pip install pytest -pipeline_test_write: - extends: .test_base +test_core: + extends: .tests script: - - python3 pipeline_test.py write + - python3 -m pytest --color=yes --junitxml=test-core.xml tests/test_core.py -pipeline_test_write_backward: - extends: .test_base +test_numpy: + extends: .tests script: - - python3 pipeline_test.py write backward + - python3 -m pytest --color=yes --junitxml=test-numpy.xml tests/test_numpy.py -pipeline_test_write_nointermediate: - extends: .test_base +test_pipeline: + extends: .tests script: - - python3 pipeline_test.py write no-intermediate-result + - python3 -m pytest --color=yes --junitxml=test-pipeline.xml tests/test_pipeline.py -pipeline_test_write_backward_nointermediate: - extends: .test_base +test_serialization: + extends: .tests script: - - python3 pipeline_test.py write backward no-intermediate-result + - python3 -m pytest --color=yes --junitxml=test-serialization.xml tests/test_serialization.py \ No newline at end of file diff --git a/README.md b/README.md index 7db6a788ab44ee1b91d3ab81444a130c3892464a..3993fce9644af09c87d5b629bba7f926ac37ea5d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Requirements: pip install pyotb --upgrade ``` -For Python>=3.6, latest version available is pyotb 1.5.0 For Python 3.5, latest version available is pyotb 1.2.2 +For Python>=3.6, latest version available is pyotb 1.5.1 For Python 3.5, latest version available is pyotb 1.2.2 ## Quickstart: running an OTB application as a oneliner pyotb has been written so that it is more convenient to run an application in Python. diff --git a/mkdocs.yml b/mkdocs.yml index 1e1e0c10aeb416c0c690c945f4aa702e54345d07..56542796967f59adaed72babd6de011ec9afd6cd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,7 +47,6 @@ nav: - functions: reference/pyotb/functions.md - helpers: reference/pyotb/helpers.md - # Customization extra: feature: @@ -69,7 +68,7 @@ markdown_extensions: - pymdownx.inlinehilite - pymdownx.snippets -# rest of the navigation.. +# Rest of the navigation.. site_name: "pyotb documentation: a Python extension of OTB" repo_url: https://gitlab.orfeo-toolbox.org/nicolasnn/pyotb repo_name: pyotb diff --git a/pyotb/__init__.py b/pyotb/__init__.py index f81141ca712c0e84d2a82afd7779353fcd1f18d8..71c0766b3f353d024a09e78d5b312278f1728d1d 100644 --- a/pyotb/__init__.py +++ b/pyotb/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """This module provides convenient python wrapping of otbApplications.""" -__version__ = "1.5.0" +__version__ = "1.5.1" from .apps import * from .core import App, Output, Input, get_nbchannels, get_pixel_type diff --git a/pyotb/core.py b/pyotb/core.py index 3827c2c4903c2b89d53efd20fd91b7b9fa78a5c7..95cca7b9b1c9af3db2086bc2efb18687ed1ce22b 100644 --- a/pyotb/core.py +++ b/pyotb/core.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- """This module is the core of pyotb.""" -from abc import ABC from pathlib import Path import numpy as np @@ -9,34 +8,31 @@ import otbApplication as otb from .helpers import logger -class otbObject(ABC): - """Abstract class that gathers common operations for any OTB in-memory raster. - - All child of this class must have an `app` attribute that is an OTB application. - """ - _parameters = {} - - @property - def parameters(self): - """Property to merge otb.Application parameters and user's parameters dicts.""" - parameters = self.app.GetParameters().items() - parameters = {k: str(v) if isinstance(v, otb.ApplicationProxy) else v for k, v in parameters} - return {**parameters, **self._parameters} +class otbObject: + """Base class that gathers common operations for any OTB in-memory raster.""" + _name = "" + app = None + output_param = "" @property - def output_param(self): - """Property to get object's unique (or first) output parameter key. + def name(self): + """Application name that will be printed in logs. Returns: - output parameter key + user's defined name or appname + + """ + return self._name or self.app.GetName() + + @name.setter + def name(self, val): + """Set custom name. + + Args: + val: new name """ - if hasattr(self, 'output_parameter_key'): # this is for Input, Output, Operation, Slicer - return self.output_parameter_key - # this is for App - if not self.output_parameters_keys: - return "" # apps without outputs - return self.output_parameters_keys[0] + self._name = val @property def dtype(self): @@ -97,8 +93,8 @@ class otbObject(ABC): typ = parse_pixel_type(pixel_type) if isinstance(self, App): dtypes = {key: typ for key in self.output_parameters_keys} - elif hasattr(self, 'output_parameter_key'): - dtypes = {self.output_parameter_key: typ} + elif isinstance(self, otbObject): + dtypes = {self.output_param: typ} if filename_extension: logger.debug('%s: using extended filename for outputs: %s', self.name, filename_extension) @@ -119,8 +115,8 @@ class otbObject(ABC): # Parse kwargs for key, output_filename in kwargs.items(): # Stop process if a bad parameter is given - if not self.__check_output_param(key): - raise KeyError(f'{self.app.GetName()}: Wrong parameter key "{key}"') + if key not in self.app.GetParametersKeys(): + raise KeyError(f'{self.app.GetName()}: Unknown parameter key "{key}"') # Check if extended filename was not provided twice if '?' in output_filename and filename_extension: logger.warning('%s: extended filename was provided twice. Using the one found in path.', self.name) @@ -137,7 +133,7 @@ class otbObject(ABC): try: self.app.WriteOutput() except RuntimeError: - logger.info('%s: failed to simply write output, executing once again then writing', self.name) + logger.debug('%s: failed to simply write output, executing once again then writing', self.name) self.app.ExecuteAndWriteOutput() def to_numpy(self, preserve_dtype=True, copy=False): @@ -182,19 +178,6 @@ class otbObject(ABC): } return array, profile - def __check_output_param(self, key): - """Check param name to prevent strange behaviour in write() if kwarg key is not implemented. - - Args: - key: parameter key - - Returns: - bool flag - """ - if hasattr(self, 'output_parameter_key'): - return key == self.output_parameter_key - return key in self.output_parameters_keys - # Special methods def __getitem__(self, key): """Override the default __getitem__ behaviour. @@ -547,11 +530,26 @@ class otbObject(ABC): return NotImplemented + def summarize(self): + """Return a nested dictionary summarizing the otbObject. + + Returns: + Nested dictionary summarizing the otbObject + + """ + params = self.parameters + for k, p in params.items(): + # In the following, we replace each parameter which is an otbObject, with its summary. + if isinstance(p, otbObject): # single parameter + params[k] = p.summarize() + elif isinstance(p, list): # parameter list + params[k] = [pi.summarize() if isinstance(pi, otbObject) else pi for pi in p] + + return {"name": self.name, "parameters": params} + class App(otbObject): """Class of an OTB app.""" - _name = "" - def __init__(self, appname, *args, frozen=False, quiet=False, preserve_dtype=False, image_dic=None, **kwargs): """Enables to init an OTB application as a oneliner. Handles in-memory connection between apps. @@ -580,43 +578,21 @@ class App(otbObject): self.quiet = quiet self.preserve_dtype = preserve_dtype self.image_dic = image_dic - if self.quiet: self.app = otb.Registry.CreateApplicationWithoutLogger(appname) else: self.app = otb.Registry.CreateApplication(appname) + self.description = self.app.GetDocLongDescription() self.output_parameters_keys = self.__get_output_parameters_keys() - self._parameters = {} + if self.output_parameters_keys: + self.output_param = self.output_parameters_keys[0] + + self.parameters = {} if (args or kwargs): self.set_parameters(*args, **kwargs) if not self.frozen: self.execute() - @property - def name(self): - """Application name that will be printed in logs. - - Returns: - user's defined name or appname - - """ - return self._name or self.appname - - @name.setter - def name(self, val): - """Set custom App name. - - Args: - val: new name - - """ - self._name = val - - @property - def description(self): - """Return app's long description from OTB documentation.""" - return self.app.GetDocLongDescription() - def set_parameters(self, *args, **kwargs): """Set some parameters of the app. @@ -651,9 +627,12 @@ class App(otbObject): self.__set_param(param, obj) except (RuntimeError, TypeError, ValueError, KeyError) as e: raise Exception(f"{self.name}: something went wrong before execution " - f"(while setting parameter {param} to '{obj}')") from e - - self._parameters.update(parameters) + f"(while setting parameter '{param}' to '{obj}')") from e + # Update _parameters using values from OtbApplication object + otb_params = self.app.GetParameters().items() + otb_params = {k: str(v) if isinstance(v, otb.ApplicationProxy) else v for k, v in otb_params} + self.parameters.update({**parameters, **otb_params}) + # Update output images pixel types if self.preserve_dtype: self.__propagate_pixel_type() @@ -666,7 +645,7 @@ class App(otbObject): raise Exception(f'{self.name}: error during during app execution') from e self.frozen = False logger.debug("%s: execution ended", self.name) - if self.__with_output(): + if self.__has_output_param_key(): logger.debug('%s: flushing data to disk', self.name) self.app.WriteOutput() self.__save_objects() @@ -708,6 +687,14 @@ class App(otbObject): return [param for param in self.app.GetParametersKeys() if self.app.GetParameterType(param) == otb.ParameterType_OutputImage] + def __has_output_param_key(self): + """Check if App has any output parameter key.""" + if not self.output_param: + return True # apps like ReadImageInfo with no filetype output param still needs to WriteOutput + types = (otb.ParameterType_OutputFilename, otb.ParameterType_OutputImage, otb.ParameterType_OutputVectorData) + outfile_params = [param for param in self.app.GetParametersKeys() if self.app.GetParameterType(param) in types] + return any(key in self.parameters for key in outfile_params) + @staticmethod def __parse_args(args): """Gather all input arguments in kwargs dict. @@ -760,34 +747,28 @@ class App(otbObject): def __propagate_pixel_type(self): """Propagate the pixel type from inputs to output. - If several inputs, the type of an arbitrary input is considered. + For several inputs, or with an image list, the type of the first input is considered. If several outputs, all outputs will have the same type. """ pixel_type = None for key, param in self.parameters.items(): - if key not in self.output_parameters_keys: + if self.__is_key_input_image(key): + if not param: + continue + if isinstance(param, list): + param = param[0] # first image in "il" try: pixel_type = get_pixel_type(param) + type_name = self.app.ConvertPixelTypeToNumpy(pixel_type) + logger.debug('%s: output(s) will be written with type "%s"', self.name, type_name) + for out_key in self.output_parameters_keys: + self.app.SetParameterOutputImagePixelType(out_key, pixel_type) + return except TypeError: pass - if isinstance(pixel_type, int): - break - if pixel_type is None: - logger.warning("%s: could not propagate pixel type from inputs to output, no valid input found", self.name) - else: - type_name = self.app.ConvertPixelTypeToNumpy(pixel_type) - logger.debug('%s: output(s) will be written with type "%s"', self.name, type_name) - for out_key in self.output_parameters_keys: - self.app.SetParameterOutputImagePixelType(out_key, pixel_type) - def __with_output(self): - """Check if App has any output parameter key.""" - if not self.output_param: - return True # apps like ReadImageInfo with no filetype output param still needs to WriteOutput - types = (otb.ParameterType_OutputFilename, otb.ParameterType_OutputImage, otb.ParameterType_OutputVectorData) - outfile_params = [param for param in self.app.GetParametersKeys() if self.app.GetParameterType(param) in types] - return any(key in self.parameters for key in outfile_params) + logger.warning("%s: could not propagate pixel type from inputs to output, no valid input found", self.name) def __save_objects(self): """Saving app parameters and outputs as attributes, so that they can be accessed with `obj.key`. @@ -810,6 +791,10 @@ class App(otbObject): if value is not None: setattr(self, key, value) + def __is_key_input_image(self, key): + """Check if a key of the App is an input parameter image list.""" + return self.app.GetParameterType(key) in (otb.ParameterType_InputImage, otb.ParameterType_InputImageList) + def __is_key_list(self, key): """Check if a key of the App is an input parameter list.""" return self.app.GetParameterType(key) in (otb.ParameterType_InputImageList, otb.ParameterType_StringList, @@ -826,7 +811,7 @@ class App(otbObject): return f'<pyotb.App {self.appname} object id {id(self)}>' -class Slicer(otbObject): +class Slicer(App): """Slicer objects i.e. when we call something like raster[:, :, 2] from Python.""" def __init__(self, x, rows, cols, channels): @@ -845,14 +830,15 @@ class Slicer(otbObject): """ # Initialize the app that will be used for writing the slicer self.name = 'Slicer' + self.output_parameter_key = 'out' - app = App('ExtractROI', {'in': x, 'mode': 'extent'}, preserve_dtype=True, frozen=True) - parameters = {} + parameters = {'in': x, 'mode': 'extent'} + super().__init__('ExtractROI', parameters, preserve_dtype=True, frozen=True) # Channel slicing if channels != slice(None, None, None): # Trigger source app execution if needed nb_channels = get_nbchannels(x) - app.app.Execute() # this is needed by ExtractROI for setting the `cl` parameter + self.app.Execute() # this is needed by ExtractROI for setting the `cl` parameter # if needed, converting int to list if isinstance(channels, int): channels = [channels] @@ -890,10 +876,8 @@ class Slicer(otbObject): {'mode.extent.lrx': cols.stop - 1}) # subtract 1 to be compliant with python convention spatial_slicing = True # Execute app - app.set_parameters(**parameters) - app.execute() - # Keeping the OTB app and the pyotb app - self.pyotb_app, self.app = app, app.app + self.set_parameters(**parameters) + self.execute() # These are some attributes when the user simply wants to extract *one* band to be used in an Operation if not spatial_slicing and isinstance(channels, list) and len(channels) == 1: @@ -901,7 +885,7 @@ class Slicer(otbObject): self.input = x -class Input(otbObject): +class Input(App): """Class for transforming a filepath to pyOTB object.""" def __init__(self, filepath): @@ -911,13 +895,8 @@ class Input(otbObject): filepath: raster file path """ - self.output_parameter_key = 'out' self.filepath = filepath - self.name = f'Input from {filepath}' - app = App('ExtractROI', filepath, preserve_dtype=True) - - # Keeping the OTB app and the pyotb app - self.pyotb_app, self.app = app, app.app + super().__init__('ExtractROI', {'in': self.filepath}, preserve_dtype=True) def __str__(self): """Return a nice string representation with input file path.""" @@ -937,15 +916,25 @@ class Output(otbObject): """ # Keeping the OTB app and the pyotb app self.pyotb_app, self.app = app, app.app - self.output_parameter_key = output_parameter_key + self.parameters = self.pyotb_app.parameters + self.output_param = output_parameter_key self.name = f'Output {output_parameter_key} from {self.app.GetName()}' + def summarize(self): + """Return the summary of the pipeline that generates the Output object. + + Returns: + Nested dictionary summarizing the pipeline that generates the Output object. + + """ + return self.pyotb_app.summarize() + def __str__(self): """Return a nice string representation with object id.""" return f'<pyotb.Output {self.app.GetName()} object, id {id(self)}>' -class Operation(otbObject): +class Operation(App): """Class for arithmetic/math operations done in Python. Example: @@ -1003,19 +992,14 @@ class Operation(otbObject): # getting unique image inputs, in the order im1, im2, im3 ... self.unique_inputs = [mapping_str_to_input[str_input] for str_input in sorted(self.im_dic, key=self.im_dic.get)] - self.output_parameter_key = 'out' + self.output_param = 'out' # Computing the BandMath or BandMathX app self.exp_bands, self.exp = self.get_real_exp(self.fake_exp_bands) self.name = f'Operation exp="{self.exp}"' - if len(self.exp_bands) == 1: - app = App('BandMath', il=self.unique_inputs, exp=self.exp) - else: - app = App('BandMathX', il=self.unique_inputs, exp=self.exp) - - # Keeping the OTB app and the pyotb app - self.pyotb_app, self.app = app, app.app + appname = 'BandMath' if len(self.exp_bands) == 1 else 'BandMathX' + super().__init__(appname, il=self.unique_inputs, exp=self.exp) def create_fake_exp(self, operator, inputs, nb_bands=None): """Create a 'fake' expression. @@ -1191,7 +1175,6 @@ class logicalOperation(Operation): """ super().__init__(operator, *inputs, nb_bands=nb_bands) - self.logical_exp_bands, self.logical_exp = self.get_real_exp(self.logical_fake_exp_bands) def create_fake_exp(self, operator, inputs, nb_bands=None): diff --git a/pyotb/functions.py b/pyotb/functions.py index 7fa05facf9409544e4e06b3dda9ca4eac62be1e9..eee401a3ba6638df6e86f9efe96cc8238c9a4655 100644 --- a/pyotb/functions.py +++ b/pyotb/functions.py @@ -7,7 +7,7 @@ import textwrap import uuid from collections import Counter -from .core import (App, Input, Operation, logicalOperation, get_nbchannels) +from .core import (otbObject, App, Input, Operation, logicalOperation, get_nbchannels) from .helpers import logger @@ -352,10 +352,10 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m for inp in inputs: if isinstance(inp, str): # this is for filepaths metadata = Input(inp).GetImageMetaData('out') - elif hasattr(inp, 'output_parameter_key'): # this is for Output, Input, Operation - metadata = inp.GetImageMetaData(inp.output_parameter_key) - else: # this is for App - metadata = inp.GetImageMetaData(inp.output_parameters_keys[0]) + elif isinstance(inp, otbObject): + metadata = inp.GetImageMetaData(inp.output_param) + else: + raise TypeError(f"Wrong input : {inp}") metadatas[inp] = metadata # Get a metadata of an arbitrary image. This is just to compare later with other images diff --git a/pyotb/helpers.py b/pyotb/helpers.py index 522fcb407931ea99f6bf081cf7726c4165242603..742fee18be4bfbf6951d8e754cb102d470b16b4a 100644 --- a/pyotb/helpers.py +++ b/pyotb/helpers.py @@ -60,8 +60,11 @@ def find_otb(prefix=OTB_ROOT, scan=True, scan_userdir=True): set_environment(prefix) import otbApplication as otb # pylint: disable=import-outside-toplevel return otb - except (ImportError, EnvironmentError) as e: + except EnvironmentError as e: raise SystemExit(f"Failed to import OTB with prefix={prefix}") from e + except ImportError as e: + __suggest_fix_import(str(e), prefix) + raise SystemExit("Failed to import OTB. Exiting.") from e # Else try import from actual Python path try: # Here, we can't properly set env variables before OTB import. We assume user did this before running python @@ -91,8 +94,6 @@ def find_otb(prefix=OTB_ROOT, scan=True, scan_userdir=True): raise SystemExit("Can't run without OTB installed. Exiting.") from e # Help user to fix this except ImportError as e: - logger.critical("An error occurred while importing OTB Python API") - logger.critical("OTB error message was '%s'", e) __suggest_fix_import(str(e), prefix) raise SystemExit("Failed to import OTB. Exiting.") from e @@ -118,13 +119,16 @@ def set_environment(prefix): raise EnvironmentError("Can't find OTB external libraries") # This does not seems to work if sys.platform == "linux" and built_from_source: - os.environ["LD_LIBRARY_PATH"] = f"{lib_dir}:{os.environ.get('LD_LIBRARY_PATH') or ''}" + new_ld_path = f"{lib_dir}:{os.environ.get('LD_LIBRARY_PATH') or ''}" + os.environ["LD_LIBRARY_PATH"] = new_ld_path # Add python bindings directory first in PYTHONPATH otb_api = __find_python_api(lib_dir) if not otb_api: raise EnvironmentError("Can't find OTB Python API") if otb_api not in sys.path: sys.path.insert(0, otb_api) + # Add /bin first in PATH, in order to avoid conflicts with another GDAL install when using os.system() + os.environ["PATH"] = f"{prefix / 'bin'}{os.pathsep}{os.environ['PATH']}" # Applications path (this can be tricky since OTB import will succeed even without apps) apps_path = __find_apps_path(lib_dir) if Path(apps_path).exists(): @@ -142,11 +146,14 @@ def set_environment(prefix): # If installed using apt or built from source with system deps gdal_data = "/usr/share/gdal" proj_lib = "/usr/share/proj" - if not Path(gdal_data).exists(): - logger.warning("Can't find GDAL directory with prefix %s or in /usr", prefix) + elif sys.platform == "win32": + gdal_data = str(prefix / "share/data") + proj_lib = str(prefix / "share/proj") else: - os.environ["GDAL_DATA"] = gdal_data - os.environ["PROJ_LIB"] = proj_lib + raise EnvironmentError(f"Can't find GDAL location with current OTB prefix '{prefix}' or in /usr") + + os.environ["GDAL_DATA"] = gdal_data + os.environ["PROJ_LIB"] = proj_lib def __find_lib(prefix=None, otb_module=None): @@ -266,8 +273,10 @@ def __find_otb_root(scan_userdir=False): def __suggest_fix_import(error_message, prefix): """Help user to fix the OTB installation with appropriate log messages.""" - if error_message.startswith('libpython3.'): - if sys.platform == "linux": + logger.critical("An error occurred while importing OTB Python API") + logger.critical("OTB error message was '%s'", error_message) + if sys.platform == "linux": + if error_message.startswith('libpython3.'): logger.critical("It seems like you need to symlink or recompile python bindings") if sys.executable.startswith('/usr/bin'): lib = f"/usr/lib/x86_64-linux-gnu/libpython3.{sys.version_info.minor}.so" @@ -285,6 +294,16 @@ def __suggest_fix_import(error_message, prefix): logger.critical("You may need to install cmake in order to recompile python bindings") else: logger.critical("Unable to automatically locate python dynamic library of %s", sys.executable) - else: - docs_link = "https://www.orfeo-toolbox.org/CookBook/Installation.html" - logger.critical("You can verify installation requirements for your OS at %s", docs_link) + return + elif sys.platform == "win32": + if error_message.startswith("DLL load failed"): + if sys.version_info.minor != 7: + logger.critical("You need Python 3.5 (OTB releases 6.4 to 7.4) or Python 3.7 (since OTB 8)") + issue_link = "https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/issues/2010" + logger.critical("Another workaround is to recompile Python bindings with cmake, see %s", issue_link) + else: + logger.critical("It seems that your env variables aren't properly set," + " first use 'call otbenv.bat' then try to import pyotb once again") + return + docs_link = "https://www.orfeo-toolbox.org/CookBook/Installation.html" + logger.critical("You can verify installation requirements for your OS at %s", docs_link) diff --git a/setup.py b/setup.py index b1a1cf3bbce5ff99229c30551e98401b5e4022a4..beded0505c4a002621e35dc8b022e6a3901f26b3 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ with open("README.md", "r", encoding="utf-8") as fh: setuptools.setup( name="pyotb", - version="1.5.0", + version="1.5.1", author="Nicolas Narçon", author_email="nicolas.narcon@gmail.com", description="Library to enable easy use of the Orfeo Tool Box (OTB) in Python", diff --git a/tests/basic_tests.py b/tests/basic_tests.py deleted file mode 100644 index d3d2d1391a4da6bbf36c8aff0a18bdf93b059f5c..0000000000000000000000000000000000000000 --- a/tests/basic_tests.py +++ /dev/null @@ -1,26 +0,0 @@ -import pyotb - -filepath = 'image.tif' -inp = pyotb.Input(filepath) -assert inp.dtype == 'uint8' -assert inp.shape == (304, 251, 4) - -# Test slicer -extract = inp[:50, :60, :3] -assert extract.dtype == 'uint8' -assert extract.shape == (50, 60, 3) - -# Test ReadImageInfo -info = pyotb.ReadImageInfo(inp, quiet=True) -assert info.sizex == 251 -assert info.sizey == 304 -assert info['numberbands'] == info.numberbands == 4 - -# Test Statistics -stats = pyotb.ComputeImagesStatistics([inp], quiet=True) -assert stats['out.min'] == "[33, 64, 91, 47]" - -# Test Statistics on a Slicer -slicer_stats = pyotb.ComputeImagesStatistics(il=[inp[:10, :10, 0]], quiet=True) -assert slicer_stats['out.min'] == '[180]' - diff --git a/tests/ndvi_test.py b/tests/ndvi_test.py deleted file mode 100644 index e469d5e1050a84811bfa764687cf54f823f0d03b..0000000000000000000000000000000000000000 --- a/tests/ndvi_test.py +++ /dev/null @@ -1,33 +0,0 @@ -import pyotb - -filepath = 'image.tif' -inp = pyotb.Input(filepath) - -# Compute NDVI with bandmath -ndvi_bandmath = (inp[:, :, -1] - inp[:, :, [0]]) / (inp[:, :, -1] + inp[:, :, 0]) -assert ndvi_bandmath.exp == '((im1b4 - im1b1) / (im1b4 + im1b1))' -ndvi_bandmath.write('/tmp/ndvi_bandmath.tif', pixel_type='float') - -# Compute NDVI with RadiometricIndices app -ndvi_indices = pyotb.RadiometricIndices({'in': inp, 'list': 'Vegetation:NDVI', - 'channels.red': 1, 'channels.nir': 4}) -ndvi_indices.write('/tmp/ndvi_indices.tif', pixel_type='float') - -compared = pyotb.CompareImages({'ref.in': ndvi_indices, 'meas.in': '/tmp/ndvi_bandmath.tif'}) -assert compared.count == 0 -assert compared.mse == 0 - -# Threshold -thresholded_indices = pyotb.where(ndvi_indices >= 0.3, 1, 0) -thresholded_bandmath = pyotb.where(ndvi_bandmath >= 0.3, 1, 0) -assert thresholded_indices.exp == '((im1b1 >= 0.3) ? 1 : 0)' -assert thresholded_bandmath.exp == '((((im1b4 - im1b1) / (im1b4 + im1b1)) >= 0.3) ? 1 : 0)' - -# Sum of bands -summed = sum(inp[:, :, b] for b in range(inp.shape[-1])) -assert summed.exp == '((((0 + im1b1) + im1b2) + im1b3) + im1b4)' - -# Create binary mask based on several possible values -values = [1, 2, 3, 4] -res = pyotb.where(pyotb.any(inp[:, :, 0] == value for value in values), 255, 0) -assert res.exp == '(((((im1b1 == 1) || (im1b1 == 2)) || (im1b1 == 3)) || (im1b1 == 4)) ? 255 : 0)' diff --git a/tests/numpy_array_test.py b/tests/numpy_array_test.py deleted file mode 100644 index 025be54961ad712549ab7c3ff18243152afb8587..0000000000000000000000000000000000000000 --- a/tests/numpy_array_test.py +++ /dev/null @@ -1,44 +0,0 @@ -import numpy as np -import pyotb -from osgeo import osr - -filepath = 'image.tif' - -# Test to_numpy array -inp = pyotb.Input(filepath) -array = inp.to_numpy() -assert array.dtype == np.uint8 -assert array.shape == (304, 251, 4) - -# Test to_numpy array with slicer -inp = pyotb.Input(filepath)[:100, :200, :3] -array = inp.to_numpy() -assert array.dtype == np.uint8 -assert array.shape == (100, 200, 3) - -# Test conversion to numpy array -array = np.array(inp) -assert isinstance(array, np.ndarray) -assert inp.shape == array.shape - -# Test image + noise README example -white_noise = np.random.normal(0, 50, size=inp.shape) -noisy_image = inp + white_noise -assert isinstance(noisy_image, pyotb.App) -assert noisy_image.shape == inp.shape - -# Test to_rasterio -array, profile = inp.to_rasterio() -# Data type and shape -assert array.dtype == profile['dtype'] == np.uint8 -assert array.shape == (3, 100, 200) -# Array statistics -assert array.min() == 35 -assert array.max() == 255 -# Spatial reference -assert profile['transform'] == (6.0, 0.0, 760056.0, 0.0, -6.0, 6946092.0) -crs = osr.SpatialReference() -crs.ImportFromEPSG(2154) -dest_crs = osr.SpatialReference() -dest_crs.ImportFromWkt(profile['crs']) -assert dest_crs.IsSame(crs) diff --git a/tests/pipeline_test.py b/tests/pipeline_test.py deleted file mode 100644 index a3e8c660278ed1fed8377ac25a064ab2fc9378a2..0000000000000000000000000000000000000000 --- a/tests/pipeline_test.py +++ /dev/null @@ -1,189 +0,0 @@ -import sys -import itertools -import os -import pyotb - -# List of buildings blocks -# We can add other pyotb objects here -OTBAPPS_BLOCKS = [ - # lambda inp: pyotb.ExtractROI({"in": inp, "startx": 10, "starty": 10, "sizex": 50, "sizey": 50}), - lambda inp: pyotb.ManageNoData({"in": inp, "mode": "changevalue"}), - lambda inp: pyotb.DynamicConvert({"in": inp}), - lambda inp: pyotb.Mosaic({"il": [inp]}), - lambda inp: pyotb.BandMath({"il": [inp], "exp": "im1b1 + 1"}), - lambda inp: pyotb.BandMathX({"il": [inp], "exp": "im1"}) -] - -PYOTB_BLOCKS = [ - lambda inp: 1 + abs(inp) * 2, - lambda inp: inp[:80, 10:60, :], -] - -ALL_BLOCKS = PYOTB_BLOCKS + OTBAPPS_BLOCKS - - -def backward(): - """ - Return True if backward mode. - In backward mode applications are tested from the end to the beginning of the pipeline. - """ - - -def check_app_write(app, out): - """ - Check that the app write correctly its output - """ - print(f"Checking app {app.name} writing") - - # Remove output file it already there - if os.path.isfile(out): - os.remove(out) - # Write - try: - app.write(out) - except Exception as e: - print("\n\033[91mWRITE ERROR\033[0m") - print(e) - return False - # Report - if not os.path.isfile(out): - return False - return True - - -filepath = os.environ["PIPELINE_TEST_INPUT_IMAGE"] -pyotb_input = pyotb.Input(filepath) -args = [arg.lower() for arg in sys.argv[1:]] if len(sys.argv) > 1 else [] - - -def generate_pipeline(inp, building_blocks): - """ - Create pipeline formed with the given building blocks - - Args: - inp: input - building_blocks: building blocks - - Returns: - pipeline - - """ - # Create the N apps pipeline - pipeline = [] - for app in building_blocks: - new_app_inp = pipeline[-1] if pipeline else inp - new_app = app(new_app_inp) - pipeline.append(new_app) - return pipeline - - -def combi(building_blocks, length): - """Returns all possible combinations of N unique buildings blocks - - Args: - building_blocks: building blocks - length: length - - Returns: - list of combinations - - """ - av = list(itertools.combinations(building_blocks, length)) - al = [] - for a in av: - al += itertools.permutations(a) - - return list(set(al)) - - -def test_pipeline(pipeline): - """Test the pipeline - - Args: - pipeline: pipeline (list of pyotb objects) - - """ - report = {"shapes_errs": [], "write_errs": []} - - # Test outputs shapes - pipeline_items = [pipeline[-1]] if "no-intermediate-result" in args else pipeline - generator = lambda: enumerate(pipeline_items) - if "backward" in args: - print("Perform tests in backward mode") - generator = lambda: enumerate(reversed(pipeline_items)) - if "shape" in args: - for i, app in generator(): - try: - print(f"Trying to access shape of app {app.name} output...") - shape = app.shape - print(f"App {app.name} output shape is {shape}") - except Exception as e: - print("\n\033[91mGET SHAPE ERROR\033[0m") - print(e) - report["shapes_errs"].append(i) - - # Test all pipeline outputs - if "write" in args: - for i, app in generator(): - if not check_app_write(app, f"/tmp/out_{i}.tif"): - report["write_errs"].append(i) - - return report - - -def pipeline2str(pipeline): - """Prints the pipeline blocks - - Args: - pipeline: pipeline - - Returns: - a string - - """ - return " \u2192 ".join([inp.__class__.__name__] + [f"{i}.{app.name.split(' ')[0]}" - for i, app in enumerate(pipeline)]) - - -# Generate pipelines of different length -blocks = {filepath: OTBAPPS_BLOCKS, # for filepath, we can't use Slicer or Operation - pyotb_input: ALL_BLOCKS} -pipelines = [] -for inp, blocks in blocks.items(): - for length in [1, 2, 3]: - print(f"Testing pipelines of length {length}") - blocks_combis = combi(building_blocks=blocks, length=length) - for block_combi in blocks_combis: - pipelines.append(generate_pipeline(inp, block_combi)) - -# Test pipelines -pipelines.sort(key=lambda x: f"{len(x)}" "".join([app.name for app in x])) # Sort by length then alphabetical -results = {} -for pipeline in pipelines: - print("\033[94m" f"\nTesting the following pipeline: {pipeline2str(pipeline)}\n" "\033[0m") - results.update({tuple(pipeline): test_pipeline(pipeline)}) - -# Summary -cols = max([len(pipeline2str(pipeline)) for pipeline in pipelines]) + 1 -print(f'Tests summary (\033[93mTest options: {"; ".join(args)}\033[0m)') -print("Pipeline".ljust(cols) + " | Status (reason)") -print("-" * cols + "-|-" + "-" * 20) -nb_fails = 0 -allowed_to_fail = 0 -for pipeline, errs in results.items(): - has_err = sum(len(value) for key, value in errs.items()) > 0 - graph = pipeline2str(pipeline) - msg = graph + " ".ljust(cols - len(graph)) - if has_err: - msg = f"\033[91m{msg}\033[0m" - msg += " | " - if has_err: - causes = [f"{section}: " + ", ".join([f"app{i}" for i in out_ids]) - for section, out_ids in errs.items() if out_ids] - msg += "\033[91mFAIL\033[0m (" + "; ".join(causes) + ")" - nb_fails += 1 - else: - msg += "\033[92mPASS\033[0m" - print(msg) -print(f"End of summary ({nb_fails} error(s))", flush=True) -assert nb_fails == 0, "One of the pipelines have failed. Please read the report." diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000000000000000000000000000000000000..abefc80c7c5a03c06360e900d2e7dabee4e116b3 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,88 @@ +import os +import pyotb +from ast import literal_eval +from pathlib import Path + + +FILEPATH = os.environ["TEST_INPUT_IMAGE"] +INPUT = pyotb.Input(FILEPATH) + + +# Basic tests +def test_dtype(): + assert INPUT.dtype == "uint8" + + +def test_shape(): + assert INPUT.shape == (304, 251, 4) + + +def test_slicer_shape(): + extract = INPUT[:50, :60, :3] + assert extract.shape == (50, 60, 3) + + +def test_slicer_preserve_dtype(): + extract = INPUT[:50, :60, :3] + assert extract.dtype == "uint8" + + +# More complex tests +def test_operation(): + op = INPUT / 255 * 128 + assert op.exp == "((im1b1 / 255) * 128);((im1b2 / 255) * 128);((im1b3 / 255) * 128);((im1b4 / 255) * 128)" + + +def test_sum_bands(): + # Sum of bands + summed = sum(INPUT[:, :, b] for b in range(INPUT.shape[-1])) + assert summed.exp == "((((0 + im1b1) + im1b2) + im1b3) + im1b4)" + + +def test_binary_mask_where(): + # Create binary mask based on several possible values + values = [1, 2, 3, 4] + res = pyotb.where(pyotb.any(INPUT[:, :, 0] == value for value in values), 255, 0) + assert res.exp == "(((((im1b1 == 1) || (im1b1 == 2)) || (im1b1 == 3)) || (im1b1 == 4)) ? 255 : 0)" + + +# Apps +def test_app_readimageinfo(): + info = pyotb.ReadImageInfo(INPUT, quiet=True) + assert info.sizex == 251 + assert info.sizey == 304 + assert info["numberbands"] == info.numberbands == 4 + + +def test_app_computeimagestats(): + stats = pyotb.ComputeImagesStatistics([INPUT], quiet=True) + assert stats["out.min"] == "[33, 64, 91, 47]" + + +def test_app_computeimagestats_sliced(): + slicer_stats = pyotb.ComputeImagesStatistics(il=[INPUT[:10, :10, 0]], quiet=True) + assert slicer_stats["out.min"] == "[180]" + + +# NDVI +def test_ndvi_comparison(): + ndvi_bandmath = (INPUT[:, :, -1] - INPUT[:, :, [0]]) / (INPUT[:, :, -1] + INPUT[:, :, 0]) + ndvi_indices = pyotb.RadiometricIndices( + {"in": INPUT, "list": "Vegetation:NDVI", "channels.red": 1, "channels.nir": 4} + ) + assert ndvi_bandmath.exp == "((im1b4 - im1b1) / (im1b4 + im1b1))" + + ndvi_bandmath.write("/tmp/ndvi_bandmath.tif", pixel_type="float") + assert Path("/tmp/ndvi_bandmath.tif").exists() + ndvi_indices.write("/tmp/ndvi_indices.tif", pixel_type="float") + assert Path("/tmp/ndvi_indices.tif").exists() + + compared = pyotb.CompareImages({"ref.in": ndvi_indices, "meas.in": "/tmp/ndvi_bandmath.tif"}) + assert compared.count == 0 + assert compared.mse == 0 + + thresholded_indices = pyotb.where(ndvi_indices >= 0.3, 1, 0) + assert thresholded_indices.exp == "((im1b1 >= 0.3) ? 1 : 0)" + + thresholded_bandmath = pyotb.where(ndvi_bandmath >= 0.3, 1, 0) + assert thresholded_bandmath.exp == "((((im1b4 - im1b1) / (im1b4 + im1b1)) >= 0.3) ? 1 : 0)" diff --git a/tests/test_numpy.py b/tests/test_numpy.py new file mode 100644 index 0000000000000000000000000000000000000000..5b1dd048cb73d2a9c904c3b937c6d60d8b8520c3 --- /dev/null +++ b/tests/test_numpy.py @@ -0,0 +1,53 @@ +import os +import numpy as np +import pyotb + + +FILEPATH = os.environ["TEST_INPUT_IMAGE"] +INPUT = pyotb.Input(FILEPATH) + + +def test_to_numpy(): + array = INPUT.to_numpy() + assert array.dtype == np.uint8 + assert array.shape == INPUT.shape + assert array.min() == 33 + assert array.max() == 255 + + +def test_to_numpy_sliced(): + sliced = INPUT[:100, :200, :3] + array = sliced.to_numpy() + assert array.dtype == np.uint8 + assert array.shape == (100, 200, 3) + + +def test_convert_to_array(): + array = np.array(INPUT) + assert isinstance(array, np.ndarray) + assert INPUT.shape == array.shape + + +def test_add_noise_array(): + white_noise = np.random.normal(0, 50, size=INPUT.shape) + noisy_image = INPUT + white_noise + assert isinstance(noisy_image, pyotb.otbObject) + assert noisy_image.shape == INPUT.shape + + +def test_to_rasterio(): + array, profile = INPUT.to_rasterio() + assert array.dtype == profile["dtype"] == np.uint8 + assert array.shape == (4, 304, 251) + assert profile["transform"] == (6.0, 0.0, 760056.0, 0.0, -6.0, 6946092.0) + + # CRS test requires GDAL python bindings + try: + from osgeo import osr + crs = osr.SpatialReference() + crs.ImportFromEPSG(2154) + dest_crs = osr.SpatialReference() + dest_crs.ImportFromWkt(profile["crs"]) + assert dest_crs.IsSame(crs) + except ImportError: + pass diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..c2d8f0e910851f5412f044c5d4ef887613af57a0 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,153 @@ +import sys +import os +import itertools +import pytest +import pyotb +from pyotb.helpers import logger + + +# List of buildings blocks, we can add other pyotb objects here +OTBAPPS_BLOCKS = [ + # lambda inp: pyotb.ExtractROI({"in": inp, "startx": 10, "starty": 10, "sizex": 50, "sizey": 50}), + lambda inp: pyotb.ManageNoData({"in": inp, "mode": "changevalue"}), + lambda inp: pyotb.DynamicConvert({"in": inp}), + lambda inp: pyotb.Mosaic({"il": [inp]}), + lambda inp: pyotb.BandMath({"il": [inp], "exp": "im1b1 + 1"}), + lambda inp: pyotb.BandMathX({"il": [inp], "exp": "im1"}) +] + +PYOTB_BLOCKS = [ + lambda inp: 1 + abs(inp) * 2, + lambda inp: inp[:80, 10:60, :], +] +PIPELINES_LENGTH = [1, 2, 3] + +ALL_BLOCKS = PYOTB_BLOCKS + OTBAPPS_BLOCKS +FILEPATH = os.environ["TEST_INPUT_IMAGE"] +INPUT = pyotb.Input(FILEPATH) + + +def generate_pipeline(inp, building_blocks): + """ + Create pipeline formed with the given building blocks + + Args: + inp: input + building_blocks: building blocks + + Returns: + pipeline + + """ + # Create the N apps pipeline + pipeline = [] + for app in building_blocks: + new_app_inp = pipeline[-1] if pipeline else inp + new_app = app(new_app_inp) + pipeline.append(new_app) + return pipeline + + +def combi(building_blocks, length): + """Returns all possible combinations of N unique buildings blocks + + Args: + building_blocks: building blocks + length: length + + Returns: + list of combinations + + """ + av = list(itertools.combinations(building_blocks, length)) + al = [] + for a in av: + al += itertools.permutations(a) + + return list(set(al)) + + +def pipeline2str(pipeline): + """Prints the pipeline blocks + + Args: + pipeline: pipeline + + Returns: + a string + + """ + return " > ".join([INPUT.__class__.__name__] + [f"{i}.{app.name.split()[0]}" + for i, app in enumerate(pipeline)]) + + +def make_pipelines_list(): + """Create a list of pipelines using different lengths and blocks""" + blocks = {FILEPATH: OTBAPPS_BLOCKS, # for filepath, we can't use Slicer or Operation + INPUT: ALL_BLOCKS} + pipelines = [] + names = [] + for inp, blocks in blocks.items(): + # Generate pipelines of different length + for length in PIPELINES_LENGTH: + blocks_combis = combi(building_blocks=blocks, length=length) + for block in blocks_combis: + pipe = generate_pipeline(inp, block) + name = pipeline2str(pipe) + if name not in names: + pipelines.append(pipe) + names.append(name) + + return pipelines, names + + +PIPELINES, NAMES = make_pipelines_list() + + +@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES) +def test_pipeline_shape(pipe): + for i, app in enumerate(pipe): + print(app.shape) + assert bool(app.shape) + + +@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES) +def test_pipeline_shape_nointermediate(pipe): + app = [pipe[-1]][0] + assert bool(app.shape) + + +@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES) +def test_pipeline_shape_backward(pipe): + for i, app in enumerate(reversed(pipe)): + assert bool(app.shape) + + +@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES) +def test_pipeline_write(pipe): + for i, app in enumerate(pipe): + out = f"/tmp/out_{i}.tif" + if os.path.isfile(out): + os.remove(out) + app.write(out) + assert os.path.isfile(out) + + +@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES) +def test_pipeline_write_nointermediate(pipe): + app = [pipe[-1]][0] + out = f"/tmp/out_0.tif" + if os.path.isfile(out): + os.remove(out) + app.write(out) + assert os.path.isfile(out) + + +@pytest.mark.parametrize("pipe", PIPELINES, ids=NAMES) +def test_pipeline_write_backward(pipe): + for i, app in enumerate(reversed(pipe)): + out = f"/tmp/out_{i}.tif" + if os.path.isfile(out): + os.remove(out) + app.write(out) + assert os.path.isfile(out) diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000000000000000000000000000000000000..760747f12a683cd9edd7b5976d7e2a0a9361e297 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,43 @@ +import os +import pyotb + +filepath = os.environ["TEST_INPUT_IMAGE"] + + +def test_pipeline_simple(): + # BandMath -> OrthoRectification -> ManageNoData + app1 = pyotb.BandMath({'il': [filepath], 'exp': 'im1b1'}) + app2 = pyotb.OrthoRectification({'io.in': app1}) + app3 = pyotb.ManageNoData({'in': app2}) + summary = app3.summarize() + reference = {'name': 'ManageNoData', + 'parameters': {'in': {'name': 'OrthoRectification', + 'parameters': {'io.in': {'name': 'BandMath', + 'parameters': {'il': ('tests/image.tif',), 'exp': 'im1b1'}}, + 'map': 'utm', + 'outputs.isotropic': True}}, + 'mode': 'buildmask'}} + assert summary == reference + +def test_pipeline_diamond(): + # Diamond graph + app1 = pyotb.BandMath({'il': [filepath], 'exp': 'im1b1'}) + app2 = pyotb.OrthoRectification({'io.in': app1}) + app3 = pyotb.ManageNoData({'in': app2}) + app4 = pyotb.BandMathX({'il': [app2, app3], 'exp': 'im1+im2'}) + summary = app4.summarize() + reference = {'name': 'BandMathX', + 'parameters': {'il': [{'name': 'OrthoRectification', + 'parameters': {'io.in': {'name': 'BandMath', + 'parameters': {'il': ('tests/image.tif',), 'exp': 'im1b1'}}, + 'map': 'utm', + 'outputs.isotropic': True}}, + {'name': 'ManageNoData', + 'parameters': {'in': {'name': 'OrthoRectification', + 'parameters': {'io.in': {'name': 'BandMath', + 'parameters': {'il': ('tests/image.tif',), 'exp': 'im1b1'}}, + 'map': 'utm', + 'outputs.isotropic': True}}, + 'mode': 'buildmask'}}], + 'exp': 'im1+im2'}} + assert summary == reference