diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a6610b0295a65bf470341e088b673963192a1a56..b7d75da736137b1a5a41a2a8f9c0ed2f503b9694 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -83,6 +83,7 @@ pages: allow_failure: false before_script: - pip install . + - cd tests variables: OTB_ROOT: /opt/otb LD_LIBRARY_PATH: /opt/otb/lib @@ -97,12 +98,45 @@ import_pyotb: compute_ndvi: extends: .test_base script: - - cd tests - python3 ndvi_test.py -pipeline_test: +pipeline_test_shape: extends: .test_base script: - - cd tests - - python3 pipeline_test.py - - python3 pipeline_test.py backward + - 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 + +pipeline_test_write: + extends: .test_base + script: + - python3 pipeline_test.py write + +pipeline_test_write_backward: + extends: .test_base + script: + - python3 pipeline_test.py write backward + +pipeline_test_write_nointermediate: + extends: .test_base + script: + - python3 pipeline_test.py write no-intermediate-result + +pipeline_test_write_backward_nointermediate: + extends: .test_base + script: + - python3 pipeline_test.py write backward no-intermediate-result + diff --git a/README.md b/README.md index 9879d45849f52e69c2d3a72a74f0fa0e5cae23a2..6f0adcd07969ae3eb2d46d78113cb1f364ec6a68 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.4.0 For Python 3.5, latest version available is pyotb 1.2.2 +For Python>=3.6, latest version available is pyotb 1.4.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/doc/index.md b/doc/index.md index eb936d5f7bee40fe30238214e817770a4576c66b..efebb2952b8a6b8f7d43252af6736722464b325f 100644 --- a/doc/index.md +++ b/doc/index.md @@ -1,4 +1,4 @@ -# Pyotb +# Pyotb: Orfeo Toolbox for Python pyotb is a Python extension of Orfeo Toolbox. It has been built on top of the existing Python API of OTB, in order to make OTB more Python friendly. @@ -20,4 +20,7 @@ to make OTB more Python friendly. - [Comparison between pyotb and OTB native library](comparison_otb.md) - [OTB versions](otb_versions.md) - [Managing loggers](managing_loggers.md) +- [Troubleshooting & limitations](troubleshooting.md) + + ## API diff --git a/doc/installation.md b/doc/installation.md index 991ecb9ce7934253361d596aeadf23bb4254ed3b..6c79ef84a3b70f2ea8f6ba5f92d3c34fc458d1a6 100644 --- a/doc/installation.md +++ b/doc/installation.md @@ -8,4 +8,4 @@ pip install pyotb --upgrade ``` -For Python>=3.6, latest version available is pyotb 1.4.0. For Python 3.5, latest version available is pyotb 1.2.2 +For Python>=3.6, latest version available is pyotb 1.4.1. For Python 3.5, latest version available is pyotb 1.2.2 diff --git a/doc/interaction.md b/doc/interaction.md index e8493a8408d14169587fa57feb3b86a37defb556..6c87417d10ecc5d6e7a3556411a331bb099aa3f7 100644 --- a/doc/interaction.md +++ b/doc/interaction.md @@ -33,6 +33,7 @@ noisy_image = inp + white_noise # magic: this is a pyotb object that has the sa noisy_image.write('image_plus_noise.tif') ``` Limitations : + - The whole image is loaded into memory - The georeference can not be modified. Thus, numpy operations can not change the image or pixel size @@ -70,9 +71,11 @@ res = scalar_product('image1.tif', 'image2.tif') # magic: this is a pyotb objec ``` Advantages : + - The process supports streaming, hence the whole image is **not** loaded into memory - Can be integrated in OTB pipelines Limitations : + - It is not possible to use the tensorflow python API inside a script where OTBTF is used because of compilation issues between Tensorflow and OTBTF, i.e. `import tensorflow` doesn't work in a script where OTBTF apps have been initialized diff --git a/doc/troubleshooting.md b/doc/troubleshooting.md new file mode 100644 index 0000000000000000000000000000000000000000..e6b0b4c375e6c862d31b37d636cb86047ce36700 --- /dev/null +++ b/doc/troubleshooting.md @@ -0,0 +1,73 @@ +## Troubleshooting: Known limitations + +### Failure of intermediate writing + +When chaining applications in-memory, there may be some problems when writing intermediate results, depending on the order +the writings are requested. Some examples can be found below: + +#### Example of failures involving slicing + +For some applications (non-exhaustive know list: OpticalCalibration, DynamicConvert, BandMath), we can face unexpected +failures when using channels slicing +```python +import pyotb + +inp = pyotb.DynamicConvert('raster.tif') +one_band = inp[:, :, 1] + +# this works +one_band.write('one_band.tif') + +# this works +one_band.write('one_band.tif') +inp.write('stretched.tif') + +# this does not work +inp.write('stretched.tif') +one_band.write('one_band.tif') # Failure here +``` + +When writing is triggered right after the application declaration, no problem occurs: +```python +import pyotb + +inp = pyotb.DynamicConvert('raster.tif') +inp.write('stretched.tif') + +one_band = inp[:, :, 1] +one_band.write('one_band.tif') # no failure +``` + +Also, when using only spatial slicing, no issue has been reported: + +```python +import pyotb + +inp = pyotb.DynamicConvert('raster.tif') +one_band = inp[:100, :100, :] + +# this works +inp.write('stretched.tif') +one_band.write('one_band.tif') +``` + + +#### Example of failures involving arithmetic operation + +One can meet errors when using arithmetic operations at the end of a pipeline when DynamicConvert, BandMath or +OpticalCalibration is involved: + +```python +import pyotb + +inp = pyotb.DynamicConvert('raster.tif') +inp_new = pyotb.ManageNoData(inp, mode='changevalue') +absolute = abs(inp) + +# this does not work +inp.write('one_band.tif') +inp_new.write('one_band_nodata.tif') # Failure here +absolute.write('absolute.tif') # Failure here +``` + +When writing only the final result, i.e. the end of the pipeline (`absolute.write('absolute.tif')`), there is no problem. diff --git a/mkdocs.yml b/mkdocs.yml index 7dcadf54dce2f84959a88133aff7c1c933ffa56c..07c04c26b9dc550aea3f636f5d35ca97c12c99f2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,7 +27,7 @@ plugins: nav: - Home: index.md - Get Started: - - Installation: installation.md + - Installation of pyotb: installation.md - How to use pyotb: quickstart.md - Useful features: features.md - Functions: functions.md @@ -39,6 +39,7 @@ nav: - Comparison between pyotb and OTB native library: comparison_otb.md - OTB versions: otb_versions.md - Managing loggers: managing_loggers.md + - Troubleshooting & limitations: troubleshooting.md - API: - pyotb: - core: reference/pyotb/core.md @@ -69,7 +70,7 @@ markdown_extensions: - pymdownx.snippets # rest of the navigation.. -site_name: pyotb +site_name: "pyotb documentation: a Python extension of OTB" repo_url: https://gitlab.orfeo-toolbox.org/nicolasnn/pyotb repo_name: pyotb docs_dir: doc/ diff --git a/pyotb/__init__.py b/pyotb/__init__.py index a57a62302ac09232937ed4ad68c00d939a9cc01a..561ef64f648195749ade4e402b353176c49c331f 100644 --- a/pyotb/__init__.py +++ b/pyotb/__init__.py @@ -2,7 +2,7 @@ """ This module provides convenient python wrapping of otbApplications """ -__version__ = "1.4.0" +__version__ = "1.4.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 598185adcbf1660024f923250c7d73fa45c421df..791f088469d8177443890f42c6dd993afb244ec2 100644 --- a/pyotb/core.py +++ b/pyotb/core.py @@ -120,8 +120,6 @@ class otbObject(ABC): if key in dtypes: self.app.SetParameterOutputImagePixelType(key, dtypes[key]) - self.app.PropagateConnectMode(False) - if isinstance(self, App): return self.execute() @@ -176,8 +174,9 @@ class otbObject(ABC): if isinstance(self, App): if not self.finished: self.execute() - elif isinstance(self, Output): - self.app.Execute() + elif isinstance(self, otbObject): + if not self.pyotb_app.finished: + self.pyotb_app.execute() # Special methods def __getitem__(self, key): @@ -549,184 +548,12 @@ class otbObject(ABC): return NotImplemented -class Slicer(otbObject): - """Slicer objects i.e. when we call something like raster[:, :, 2] from Python""" - - def __init__(self, x, rows, cols, channels): - """ - Create a slicer object, that can be used directly for writing or inside a BandMath : - - an ExtractROI app that handles extracting bands and ROI and can be written to disk or used in pipelines - - in case the user only wants to extract one band, an expression such as "im1b#" - - Args: - x: input - rows: rows slicing (e.g. 100:2000) - cols: columns slicing (e.g. 100:2000) - channels: channels, can be slicing, list or int - - """ - # Initialize the app that will be used for writing the slicer - self.output_parameter_key = 'out' - self.name = 'Slicer' - # Trigger source app execution if needed - x.execute_if_needed() - app = App('ExtractROI', {'in': x, 'mode': 'extent'}, propagate_pixel_type=True) - # First exec required in order to read image dim - app.app.Execute() - - parameters = {} - # Channel slicing - nb_channels = get_nbchannels(x) - if channels != slice(None, None, None): - # if needed, converting int to list - if isinstance(channels, int): - channels = [channels] - # if needed, converting slice to list - elif isinstance(channels, slice): - channels_start = channels.start if channels.start is not None else 0 - channels_end = channels.stop if channels.stop is not None else nb_channels - channels_step = channels.step if channels.step is not None else 1 - channels = range(channels_start, channels_end, channels_step) - elif isinstance(channels, tuple): - channels = list(channels) - elif not isinstance(channels, list): - raise ValueError(f'Invalid type for channels, should be int, slice or list of bands. : {channels}') - - # Change the potential negative index values to reverse index - channels = [c if c >= 0 else nb_channels + c for c in channels] - parameters.update({'cl': [f'Channel{i + 1}' for i in channels]}) - - # Spatial slicing - spatial_slicing = False - # TODO: handle PixelValue app so that accessing value is possible, e.g. raster[120, 200, 0] - # TODO TBD: handle the step value in the slice so that NN undersampling is possible ? e.g. obj[::2, ::2] - if rows.start is not None: - parameters.update({'mode.extent.uly': rows.start}) - spatial_slicing = True - if rows.stop is not None and rows.stop != -1: - parameters.update( - {'mode.extent.lry': rows.stop - 1}) # subtract 1 to be compliant with python convention - spatial_slicing = True - if cols.start is not None: - parameters.update({'mode.extent.ulx': cols.start}) - spatial_slicing = True - if cols.stop is not None and cols.stop != -1: - parameters.update( - {'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, not the pyotb app - self.app = app.app - - # 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: - self.one_band_sliced = channels[0] + 1 # OTB convention: channels start at 1 - self.input = x - - -class Input(otbObject): - """ - Class for transforming a filepath to pyOTB object - """ - - def __init__(self, filepath): - """ - Args: - filepath: raster file path - - """ - self.output_parameter_key = 'out' - self.filepath = filepath - self.name = f'Input from {filepath}' - app = App('ExtractROI', filepath, execute=True, propagate_pixel_type=True) - self.app = app.app - - def __str__(self): - """ - Returns: - string representation - - """ - return f'<pyotb.Input object from {self.filepath}>' - - -class Output(otbObject): - """ - Class for output of an app - """ - - def __init__(self, app, output_parameter_key): - """ - Args: - app: The OTB application - output_parameter_key: Output parameter key - - """ - self.app = app # keeping a reference of the OTB app - self.output_parameter_key = output_parameter_key - self.name = f'Output {output_parameter_key} from {self.app.GetName()}' - - def __str__(self): - """ - Returns: - string representation - - """ - return f'<pyotb.Output {self.app.GetName()} object, id {id(self)}>' - - class App(otbObject): """ Class of an OTB app """ _name = "" - @property - def name(self): - """ - 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 finished(self): - """ - Property to store whether App has been executed but False if any output file is missing - - Returns: - True if exec ended and output files are found else False - - """ - if self._ended and self.find_output(): - return True - return False - - @finished.setter - def finished(self, val): - """ - Value `_ended` will be set to True right after App.execute() or App.write(), - then find_output() is called when accessing the property - - Args: - val: True if execution ended without exceptions - - """ - self._ended = val - def __init__(self, appname, *args, execute=False, image_dic=None, otb_stdout=True, pixel_type=None, propagate_pixel_type=False, **kwargs): """ @@ -779,9 +606,6 @@ class App(otbObject): dtypes = {key: parse_pixel_type(pixel_type) for key in self.output_parameters_keys} for key, typ in dtypes.items(): self.app.SetParameterOutputImagePixelType(key, typ) - # Here we make sure that intermediate outputs will be flushed to disk - if self.__with_output(): - self.app.PropagateConnectMode(False) # Run app, write output if needed, update `finished` property if execute or not self.output_param: self.execute() @@ -789,6 +613,54 @@ class App(otbObject): else: self.__save_objects() + def get_output_parameters_keys(self): + """Get raster output parameter keys + + Returns: + output parameters keys + """ + return [param for param in self.app.GetParametersKeys() + if self.app.GetParameterType(param) == otb.ParameterType_OutputImage] + + def set_parameters(self, *args, **kwargs): + """Set some parameters of the app. When useful, e.g. for images list, this function appends the parameters + instead of overwriting them. Handles any parameters, i.e. in-memory & filepaths + + Args: + *args: Can be : - dictionary containing key-arguments enumeration. Useful when a key is python-reserved + (e.g. "in") or contains reserved characters such as a point (e.g."mode.extent.unit") + - string, App or Output, useful when the user implicitly wants to set the param "in" + - list, useful when the user implicitly wants to set the param "il" + **kwargs: keyword arguments e.g. il=['input1.tif', oApp_object2, App_object3.out], out='output.tif' + + Raises: + Exception: when the setting of a parameter failed + + """ + parameters = kwargs + parameters.update(self.__parse_args(args)) + # Going through all arguments + for param, obj in parameters.items(): + if param not in self.app.GetParametersKeys(): + raise Exception(f"{self.name}: parameter '{param}' was not recognized. " + f"Available keys are {self.app.GetParametersKeys()}") + # When the parameter expects a list, if needed, change the value to list + if self.__is_key_list(param) and not isinstance(obj, (list, tuple)): + parameters[param] = [obj] + obj = [obj] + logger.warning('%s: Argument for parameter "%s" was converted to list', self.name, param) + try: + # This is when we actually call self.app.SetParameter* + 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 + + # Update App's parameters attribute + self.parameters.update(parameters) + if self.preserve_dtype: + self.__propagate_pixel_type() + def execute(self): """ Execute with appropriate and outputs to disk if any output parameter was set @@ -797,12 +669,11 @@ class App(otbObject): boolean flag that indicate if command executed with success """ - success = False logger.debug("%s: run execute() with parameters=%s", self.name, self.parameters) try: self.app.Execute() if self.__with_output(): - self.app.WriteOutput() + self.app.ExecuteAndWriteOutput() self.finished = True except (RuntimeError, FileNotFoundError) as e: raise Exception(f'{self.name}: error during during app execution') from e @@ -813,6 +684,50 @@ class App(otbObject): return success + @property + def name(self): + """ + 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 finished(self): + """ + Property to store whether App has been executed but False if any output file is missing + + Returns: + True if exec ended and output files are found else False + + """ + if self._ended and self.find_output(): + return True + return False + + @finished.setter + def finished(self, val): + """ + Value `_ended` will be set to True right after App.execute() or App.write(), + then find_output() is called when accessing the property + + Args: + val: True if execution ended without exceptions + + """ + self._ended = val + def find_output(self): """ Find output files on disk using parameters @@ -864,54 +779,6 @@ class App(otbObject): if memory: self.app.FreeRessources() - def get_output_parameters_keys(self): - """Get raster output parameter keys - - Returns: - output parameters keys - """ - return [param for param in self.app.GetParametersKeys() - if self.app.GetParameterType(param) == otb.ParameterType_OutputImage] - - def set_parameters(self, *args, **kwargs): - """Set some parameters of the app. When useful, e.g. for images list, this function appends the parameters - instead of overwriting them. Handles any parameters, i.e. in-memory & filepaths - - Args: - *args: Can be : - dictionary containing key-arguments enumeration. Useful when a key is python-reserved - (e.g. "in") or contains reserved characters such as a point (e.g."mode.extent.unit") - - string, App or Output, useful when the user implicitly wants to set the param "in" - - list, useful when the user implicitly wants to set the param "il" - **kwargs: keyword arguments e.g. il=['input1.tif', oApp_object2, App_object3.out], out='output.tif' - - Raises: - Exception: when the setting of a parameter failed - - """ - parameters = kwargs - parameters.update(self.__parse_args(args)) - # Going through all arguments - for param, obj in parameters.items(): - if param not in self.app.GetParametersKeys(): - raise Exception(f"{self.name}: parameter '{param}' was not recognized. " - f"Available keys are {self.app.GetParametersKeys()}") - # When the parameter expects a list, if needed, change the value to list - if self.__is_key_list(param) and not isinstance(obj, (list, tuple)): - parameters[param] = [obj] - obj = [obj] - logger.warning('%s: Argument for parameter "%s" was converted to list', self.name, param) - try: - # This is when we actually call self.app.SetParameter* - 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 - - # Update App's parameters attribute - self.parameters.update(parameters) - if self.preserve_dtype: - self.__propagate_pixel_type() - # Private functions @staticmethod def __parse_args(args): @@ -932,33 +799,34 @@ class App(otbObject): """ Set one parameter, decide which otb.Application method to use depending on target object """ - # Single-parameter cases - if isinstance(obj, otbObject): - self.app.ConnectImage(param, obj.app, obj.output_param) - elif isinstance(obj, otb.Application): # this is for backward comp with plain OTB - outparamkey = [param for param in obj.GetParametersKeys() - if obj.GetParameterType(param) == otb.ParameterType_OutputImage][0] - self.app.ConnectImage(param, obj, outparamkey) - elif param == 'ram': # SetParameterValue in OTB<7.4 doesn't work for ram parameter cf gitlab OTB issue 2200 - self.app.SetParameterInt('ram', int(obj)) - elif not isinstance(obj, list): # any other parameters (str, int...) - self.app.SetParameterValue(param, obj) - # Images list - elif self.__is_key_images_list(param): - # To enable possible in-memory connections, we go through the list and set the parameters one by one - for inp in obj: - if isinstance(inp, otbObject): - self.app.ConnectImage(param, inp.app, inp.output_param) - elif isinstance(inp, otb.Application): # this is for backward comp with plain OTB - outparamkey = [param for param in inp.GetParametersKeys() if - inp.GetParameterType(param) == otb.ParameterType_OutputImage][0] - self.app.ConnectImage(param, inp, outparamkey) - else: # here `input` should be an image filepath - # Append `input` to the list, do not overwrite any previously set element of the image list - self.app.AddParameterStringList(param, inp) - # List of any other types (str, int...) - else: - self.app.SetParameterValue(param, obj) + if obj is not None: + # Single-parameter cases + if isinstance(obj, otbObject): + self.app.ConnectImage(param, obj.app, obj.output_param) + elif isinstance(obj, otb.Application): # this is for backward comp with plain OTB + outparamkey = [param for param in obj.GetParametersKeys() + if obj.GetParameterType(param) == otb.ParameterType_OutputImage][0] + self.app.ConnectImage(param, obj, outparamkey) + elif param == 'ram': # SetParameterValue in OTB<7.4 doesn't work for ram parameter cf gitlab OTB issue 2200 + self.app.SetParameterInt('ram', int(obj)) + elif not isinstance(obj, list): # any other parameters (str, int...) + self.app.SetParameterValue(param, obj) + # Images list + elif self.__is_key_images_list(param): + # To enable possible in-memory connections, we go through the list and set the parameters one by one + for inp in obj: + if isinstance(inp, otbObject): + self.app.ConnectImage(param, inp.app, inp.output_param) + elif isinstance(inp, otb.Application): # this is for backward comp with plain OTB + outparamkey = [param for param in inp.GetParametersKeys() if + inp.GetParameterType(param) == otb.ParameterType_OutputImage][0] + self.app.ConnectImage(param, inp, outparamkey) + else: # here `input` should be an image filepath + # Append `input` to the list, do not overwrite any previously set element of the image list + self.app.AddParameterStringList(param, inp) + # List of any other types (str, int...) + else: + self.app.SetParameterValue(param, obj) def __propagate_pixel_type(self): """ @@ -992,7 +860,7 @@ class App(otbObject): """ for key in self.app.GetParametersKeys(): if key in self.output_parameters_keys: # raster outputs - output = Output(self.app, key) + output = Output(self, key) setattr(self, key, output) elif key not in ('parameters',): # any other attributes (scalars...) try: @@ -1004,22 +872,15 @@ class App(otbObject): """ Check if a key of the App is an input parameter list """ - return self.app.GetParameterType(key) in ( - otb.ParameterType_InputImageList, - otb.ParameterType_StringList, - otb.ParameterType_InputFilenameList, - otb.ParameterType_InputVectorDataList, - otb.ParameterType_ListView - ) + return self.app.GetParameterType(key) in (otb.ParameterType_InputImageList, otb.ParameterType_StringList, + otb.ParameterType_InputFilenameList, otb.ParameterType_ListView, + otb.ParameterType_InputVectorDataList) def __is_key_images_list(self, key): """ Check if a key of the App is an input parameter image list """ - return self.app.GetParameterType(key) in ( - otb.ParameterType_InputImageList, - otb.ParameterType_InputFilenameList - ) + return self.app.GetParameterType(key) in (otb.ParameterType_InputImageList, otb.ParameterType_InputFilenameList) # Special methods def __str__(self): @@ -1029,6 +890,135 @@ class App(otbObject): return f'<pyotb.App {self.appname} object id {id(self)}>' +class Slicer(otbObject): + """Slicer objects i.e. when we call something like raster[:, :, 2] from Python""" + + def __init__(self, x, rows, cols, channels): + """ + Create a slicer object, that can be used directly for writing or inside a BandMath. It contains : + - an ExtractROI app that handles extracting bands and ROI and can be written to disk or used in pipelines + - in case the user only wants to extract one band, an expression such as "im1b#" + + Args: + x: input + rows: rows slicing (e.g. 100:2000) + cols: columns slicing (e.g. 100:2000) + channels: channels, can be slicing, list or int + + """ + # Initialize the app that will be used for writing the slicer + self.output_parameter_key = 'out' + self.name = 'Slicer' + app = App('ExtractROI', {'in': x, 'mode': 'extent'}, propagate_pixel_type=True) + + parameters = {} + # 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 + # if needed, converting int to list + if isinstance(channels, int): + channels = [channels] + # if needed, converting slice to list + elif isinstance(channels, slice): + channels_start = channels.start if channels.start is not None else 0 + channels_end = channels.stop if channels.stop is not None else nb_channels + channels_step = channels.step if channels.step is not None else 1 + channels = range(channels_start, channels_end, channels_step) + elif isinstance(channels, tuple): + channels = list(channels) + elif not isinstance(channels, list): + raise ValueError(f'Invalid type for channels, should be int, slice or list of bands. : {channels}') + + # Change the potential negative index values to reverse index + channels = [c if c >= 0 else nb_channels + c for c in channels] + parameters.update({'cl': [f'Channel{i + 1}' for i in channels]}) + + # Spatial slicing + spatial_slicing = False + # TODO: handle PixelValue app so that accessing value is possible, e.g. raster[120, 200, 0] + # TODO TBD: handle the step value in the slice so that NN undersampling is possible ? e.g. raster[::2, ::2] + if rows.start is not None: + parameters.update({'mode.extent.uly': rows.start}) + spatial_slicing = True + if rows.stop is not None and rows.stop != -1: + parameters.update( + {'mode.extent.lry': rows.stop - 1}) # subtract 1 to be compliant with python convention + spatial_slicing = True + if cols.start is not None: + parameters.update({'mode.extent.ulx': cols.start}) + spatial_slicing = True + if cols.stop is not None and cols.stop != -1: + parameters.update( + {'mode.extent.lrx': cols.stop - 1}) # subtract 1 to be compliant with python convention + spatial_slicing = True + # Execute app + app.set_parameters(**parameters) + + # Keeping the OTB app and the pyotb app + self.pyotb_app, self.app = app, app.app + + # 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: + self.one_band_sliced = channels[0] + 1 # OTB convention: channels start at 1 + self.input = x + + +class Input(otbObject): + """ + Class for transforming a filepath to pyOTB object + """ + + def __init__(self, filepath): + """ + Args: + filepath: raster file path + + """ + self.output_parameter_key = 'out' + self.filepath = filepath + self.name = f'Input from {filepath}' + app = App('ExtractROI', filepath, execute=True, propagate_pixel_type=True) + + # Keeping the OTB app and the pyotb app + self.pyotb_app, self.app = app, app.app + + def __str__(self): + """ + Returns: + string representation + + """ + return f'<pyotb.Input object from {self.filepath}>' + + +class Output(otbObject): + """ + Class for output of an app + """ + + def __init__(self, app, output_parameter_key): + """ + Args: + app: The pyotb App + output_parameter_key: Output parameter key + + """ + # Keeping the OTB app and the pyotb app + self.pyotb_app, self.app = app, app.app + self.output_parameter_key = output_parameter_key + self.name = f'Output {output_parameter_key} from {self.app.GetName()}' + + def __str__(self): + """ + Returns: + string representation + + """ + return f'<pyotb.Output {self.app.GetName()} object, id {id(self)}>' + + class Operation(otbObject): """ Class for arithmetic/math operations done in Python. @@ -1055,7 +1045,7 @@ class Operation(otbObject): """ Given some inputs and an operator, this function enables to transform this into an OTB application. Operations generally involve 2 inputs (+, -...). It can have only 1 input for `abs` operator. - It can have 3 inputs for the ternary operator `cond ? x : y`, + It can have 3 inputs for the ternary operator `cond ? x : y`. Args: operator: (str) one of +, -, *, /, >, <, >=, <=, ==, !=, &, |, abs, ? @@ -1101,8 +1091,9 @@ class Operation(otbObject): app = App('BandMath', il=self.unique_inputs, exp=self.exp) else: app = App('BandMathX', il=self.unique_inputs, exp=self.exp) - app.execute() - self.app = app.app + + # Keeping the OTB app and the pyotb app + self.pyotb_app, self.app = app, app.app def create_fake_exp(self, operator, inputs, nb_bands=None): """ diff --git a/setup.py b/setup.py index 7785ddcd494292cd0b9d5f2896304162759d5656..c99a42266e03f592d0fe63eead5ace726d57b207 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.4.0", + version="1.4.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/pipeline_test.py b/tests/pipeline_test.py index 3b152b5a01a08115aa4746c8a42a750eaf65687f..a3d7ff420ec7aa581708571e58d6d404ad96f0e8 100644 --- a/tests/pipeline_test.py +++ b/tests/pipeline_test.py @@ -21,6 +21,10 @@ PYOTB_BLOCKS = [ ALL_BLOCKS = PYOTB_BLOCKS + OTBAPPS_BLOCKS +# These apps are problematic when used in pipelines with intermediate outputs +# (cf https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/issues/2290) +PROBLEMATIC_APPS = ['DynamicConvert', 'BandMath'] + def backward(): """ @@ -52,7 +56,7 @@ def check_app_write(app, out): filepath = 'Data/Input/QB_MUL_ROI_1000_100.tif' 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): """ @@ -104,25 +108,27 @@ def test_pipeline(pipeline): report = {"shapes_errs": [], "write_errs": []} # Test outputs shapes - generator = enumerate(pipeline) - if len(sys.argv) > 1: - if "backward" in sys.argv[1].lower(): - print("Perform tests in backward mode") - generator = enumerate(reversed(pipeline)) - 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) + pipeline_items = [pipeline[-1]] if "no-intermediate-output" 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 - for i, app in generator: - if not check_app_write(app, f"/tmp/out_{i}.tif"): - report["write_errs"].append(i) + 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 @@ -161,10 +167,11 @@ for pipeline in pipelines: # Summary cols = max([len(pipeline2str(pipeline)) for pipeline in pipelines]) + 1 -print("Tests summary:") +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) @@ -173,12 +180,19 @@ for pipeline, errs in results.items(): msg = f"\033[91m{msg}\033[0m" msg += " | " if has_err: - nb_fails += 1 - causes = [f"{section}: " + ", ".join([str(i) for i in out_ids]) + 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) + ")" + + # There is a failure when the pipeline length is >=3, the last app is an Operation and the first app of the + # piepline is one of the problematic apps + if ("write" in args and "backward" not in args and isinstance(pipeline[-1], pyotb.Operation) + and len(pipeline) == 3 and pipeline[0].name in PROBLEMATIC_APPS): + allowed_to_fail += 1 + else: + nb_fails += 1 else: msg += "\033[92mPASS\033[0m" print(msg) -print(f"End of summary ({nb_fails} error(s)).", flush=True) +print(f"End of summary ({nb_fails} error(s), {allowed_to_fail} 'allowed to fail' error(s))", flush=True) assert nb_fails == 0, "One of the pipelines have failed. Please read the report."