diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..f1ee3c9541269e62b5d186825d155ee2810e8413 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,62 @@ +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 + +image: $CI_REGISTRY/orfeotoolbox/otb-build-env/otb-ubuntu-native-develop:20.04 + +stages: + - Static Analysis + - Test + +# --------------------------------- Static analysis --------------------------------- + +.static_analysis_base: + stage: Static Analysis + 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 + 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 + +codespell: + extends: .static_analysis_base + script: + - pip install codespell && codespell --skip="*.png,*.jpg,*git/lfs*" + +pydocstyle: + extends: .static_analysis_base + script: + - pip install pydocstyle && pydocstyle $PWD/pyotb --ignore=D400,D403,D213,D212,D202,D203,D200,D210,D205,D401,D404,D204,D415 + +# --------------------------------- Test --------------------------------- + +.test_base: + stage: Test + tags: + - light + allow_failure: false + before_script: + - pip install . + - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/opt/otb/lib/ + - ls /opt/otb/lib/ + - export OTB_APPLICATION_PATH=/opt/otb/lib/otb/applications/ + +import_pyotb: + extends: .test_base + script: + - python3 -c "import pyotb" + +compute_ndvi: + extends: .test_base + script: + - cd tests + - python3 ndvi_test.py diff --git a/doc/MISC.md b/doc/MISC.md index 4cdf0f56899b9a9b62157fabbc4e97306634e192..8da0eeace3eeb71cd0c20e92a88758fa5c072da2 100644 --- a/doc/MISC.md +++ b/doc/MISC.md @@ -1,4 +1,4 @@ -## Miscellaneous: Work with images with differents footprints / resolutions +## Miscellaneous: Work with images with different footprints / resolutions OrfeoToolBox provides a handy `Superimpose` application that enables the projection of an image into the geometry of another one. In pyotb, a function has been created to handle more than 2 images. @@ -35,4 +35,4 @@ print(s2_image.shape) # (657, 520, 4) print(vhr_image.shape) # (657, 520, 3) print(labels.shape) # (657, 520, 1) # Then we can do whichever computations with s2_image, vhr_image, labels -``` \ No newline at end of file +``` diff --git a/pyotb/__init__.py b/pyotb/__init__.py index 8a2994661fa57dc2b62d3f8728fd53fe18c828ae..8ae8d7d7260fd6eec03c07bfbdb0e8c000799c5c 100644 --- a/pyotb/__init__.py +++ b/pyotb/__init__.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- +""" +This module provides convenient python wrapping of otbApplications +""" __version__ = "1.3" from .apps import * from .core import App, Output, Input, get_nbchannels, get_pixel_type -from .functions import * +from .functions import * # pylint: disable=redefined-builtin from .tools import logger diff --git a/pyotb/apps.py b/pyotb/apps.py index 70b924782e422f29acecc9ea32b57bc6d2c5e613..243131e1a02e3a5eafd978fee53284e45a4e0670 100644 --- a/pyotb/apps.py +++ b/pyotb/apps.py @@ -12,7 +12,7 @@ from .tools import logger, find_otb, set_gdal_vars OTB_ROOT, OTB_APPLICATION_PATH = find_otb() if not OTB_ROOT: - sys.exit("Can't run without OTB. Exiting.") + raise EnvironmentError("Can't run without OTB installed.") set_gdal_vars(OTB_ROOT) @@ -28,7 +28,7 @@ def get_available_applications(as_subprocess=False): """ app_list = () if as_subprocess and sys.executable and hasattr(sys, 'ps1'): - # Currently there is an incompatibility between OTBTF and Tensorflow that causes segfault + # Currently, there is an incompatibility between OTBTF and Tensorflow that causes segfault # when OTBTF apps are used in a script where tensorflow has already been imported. # See https://github.com/remicres/otbtf/issues/28 # Thus, we run this piece of code in a clean independent `subprocess` that doesn't interact with Tensorflow @@ -36,14 +36,14 @@ def get_available_applications(as_subprocess=False): if "PYTHONPATH" not in env: env["PYTHONPATH"] = "" env["PYTHONPATH"] = ":" + str(Path(otb.__file__).parent) - env["OTB_LOGGER_LEVEL"] = "CRITICAL" # in order to supress warnings while listing applications + env["OTB_LOGGER_LEVEL"] = "CRITICAL" # in order to suppress warnings while listing applications pycmd = "import otbApplication; print(otbApplication.Registry.GetAvailableApplications())" cmd_args = [sys.executable, "-c", pycmd] try: import subprocess params = {"env": env, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE} with subprocess.Popen(cmd_args, **params) as p: - logger.debug(f"{' '.join(cmd_args[:-1])} '{pycmd}'") + logger.debug("%s %s", ' '.join(cmd_args[:-1]), pycmd) stdout, stderr = p.communicate() stdout, stderr = stdout.decode(), stderr.decode() # ast.literal_eval is secure and will raise more handy Exceptions than eval @@ -54,7 +54,7 @@ def get_available_applications(as_subprocess=False): except subprocess.SubprocessError: logger.debug("Failed to call subprocess") except (ValueError, SyntaxError, AssertionError): - logger.debug("Failed to decode output or convert to tuple :" + f"\nstdout={stdout}\nstderr={stderr}") + logger.debug("Failed to decode output or convert to tuple:\nstdout=%s\nstderr=%s", stdout, stderr) if not app_list: logger.info("Failed to list applications in an independent process. Falling back to local otb import") @@ -64,7 +64,7 @@ def get_available_applications(as_subprocess=False): logger.warning("Unable to load applications. Set env variable OTB_APPLICATION_PATH then try again") return () - logger.info(f"Successfully loaded {len(app_list)} OTB applications") + logger.info("Successfully loaded %s OTB applications", len(app_list)) return app_list @@ -85,15 +85,22 @@ class {name}(App): """ for _app in AVAILABLE_APPLICATIONS: # Default behavior for any OTB application - exec(_code_template.format(name=_app)) + exec(_code_template.format(name=_app)) # pylint: disable=exec-used # Customize the behavior for TensorflowModelServe application. The user doesn't need to set the env variable # `OTB_TF_NSOURCES`, it is handled in pyotb if _app == 'TensorflowModelServe': class TensorflowModelServe(App): - def set_nb_sources(self, *args, n_sources=None): + """ + Helper for OTBTF + """ + @staticmethod + def set_nb_sources(*args, n_sources=None): """ Set the number of sources of TensorflowModelServe. Can be either user-defined or deduced from the args + :param args: arguments + :param n_sources: number of sources. Default is None (resolves the number of sources based on the + content of the dict passed in args, where some 'source' str is found) """ if n_sources: os.environ['OTB_TF_NSOURCES'] = str(int(n_sources)) @@ -104,5 +111,11 @@ for _app in AVAILABLE_APPLICATIONS: os.environ['OTB_TF_NSOURCES'] = str(n_sources) def __init__(self, *args, n_sources=None, **kwargs): + """ + :param args: args + :param n_sources: number of sources. Default is None (resolves the number of sources based on the + content of the dict passed in args, where some 'source' str is found) + :param kwargs: kwargs + """ self.set_nb_sources(*args, n_sources=n_sources) super().__init__('TensorflowModelServe', *args, **kwargs) diff --git a/pyotb/core.py b/pyotb/core.py index 39c12cb3ad17a58e4497353dc666917dd1c11637..f5b9d341634a4dee5c9b0df7bd874acc5151526f 100644 --- a/pyotb/core.py +++ b/pyotb/core.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +""" +This module is the core of pyotb +""" import logging from abc import ABC from pathlib import Path @@ -14,6 +17,7 @@ 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. """ + @property def shape(self): """ @@ -39,7 +43,7 @@ class otbObject(ABC): Will be used for all outputs :param pixel_type: Can be : - dictionary {output_parameter_key: pixeltype} when specifying for several outputs - str (e.g. 'uint16') or otbApplication.ImagePixelType_... When there are several - outputs, all outputs are writen with this unique type + outputs, all outputs are written with this unique type Valid pixel types are double, float, uint8, uint16, uint32, int16, int32, float, double, cint16, cint32, cfloat, cdouble. :param is_intermediate: WARNING: not fully tested. whether the raster we want to write is intermediate, @@ -52,7 +56,7 @@ class otbObject(ABC): if isinstance(arg, dict): kwargs.update(arg) elif isinstance(arg, str) and kwargs: - logger.warning(f'{self.name}: Keyword arguments specified, ignoring argument "{arg}"') + logger.warning('%s: Keyword arguments specified, ignoring argument "%s"', self.name, arg) elif isinstance(arg, str): if hasattr(self, 'output_parameter_key'): # this is for Output, Operation... output_parameter_key = self.output_parameter_key @@ -85,8 +89,8 @@ class otbObject(ABC): if not out_fn.endswith('?'): out_fn += "?" out_fn += filename_extension - logger.info(f'{self.name}: Using extended filename for output.') - logger.info(f'{self.name}: write output file "{output_parameter_key}" to {out_fn}') + logger.info('%s: Using extended filename for output.', self.name) + logger.info('%s: write output file "%s" to %s', self.name, output_parameter_key, out_fn) self.app.SetParameterString(output_parameter_key, out_fn) if output_parameter_key in pixel_types: @@ -103,124 +107,206 @@ class otbObject(ABC): - slicing, i.e. selecting ROI/bands. For example, selecting first 3 bands: object[:, :, :3] selecting bands 1, 2 & 5 : object[:, :, [0, 1, 4]] selecting 1000x1000 subset : object[:1000, :1000] + :param key: attribute key + :return: attribute or Slicer """ # Accessing string attributes if isinstance(key, str): return self.__dict__.get(key) # Slicing - elif not isinstance(key, tuple) or (isinstance(key, tuple) and len(key) < 2): - raise ValueError('`{}`cannot be interpreted as valid slicing. Slicing should be 2D or 3D.'.format(key)) - elif isinstance(key, tuple) and len(key) == 2: - # adding a 3rd dimension + if not isinstance(key, tuple) or (isinstance(key, tuple) and (len(key) < 2 or len(key) > 3)): + raise ValueError(f'"{key}"cannot be interpreted as valid slicing. Slicing should be 2D or 3D.') + if isinstance(key, tuple) and len(key) == 2: + # Adding a 3rd dimension key = key + (slice(None, None, None),) (cols, rows, channels) = key return Slicer(self, rows, cols, channels) def __getattr__(self, name): """ - This method is called when the default attribute access fails. We choose to try to access the attribute of - self.app. Thus, any method of otbApplication can be used transparently on otbObject objects, - e.g. SetParameterOutputImagePixelType() or ExportImage() work + This method is called when the default attribute access fails. We try 2 things: + - 1) access the attribute `name` of self.app. Thus, any method of otbApplication can be used transparently on + otbObject objects, e.g. SetParameterOutputImagePixelType() or ExportImage() work + - 2) if the previous failed, we try to access the ParameterValue of `name`. It is useful for applications such + as PixelValue or CompareImages to access scalar "output" parameters + :param name: attribute name + :return: attribute """ - return getattr(self.app, name) + try: + res = getattr(self.app, name) + except AttributeError: + try: + # TODO: how to access scalar output parameter whose key contains a `.` ? + res = self.app.GetParameterValue(name) + except RuntimeError as e: + raise AttributeError(f'{self.name}: Could not find attribute `{name}`') from e + + return res def __add__(self, other): - """Overrides the default addition and flavours it with BandMathX""" + """ + Overrides the default addition and flavours it with BandMathX + :param other: Another otbObject + :return: self + other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('+', self, other) def __sub__(self, other): - """Overrides the default subtraction and flavours it with BandMathX""" + """ + Overrides the default subtraction and flavours it with BandMathX + :param other: Another otbObject + :return: self - other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('-', self, other) def __mul__(self, other): - """Overrides the default subtraction and flavours it with BandMathX""" + """ + Overrides the default subtraction and flavours it with BandMathX + :param other: Another otbObject + :return: self * other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('*', self, other) def __truediv__(self, other): - """Overrides the default subtraction and flavours it with BandMathX""" + """ + Overrides the default subtraction and flavours it with BandMathX + :param other: Another otbObject + :return: self / other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('/', self, other) def __radd__(self, other): - """Overrides the default reverse addition and flavours it with BandMathX""" + """ + Overrides the default reverse addition and flavours it with BandMathX + :param other: Another otbObject + :return: other + self + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('+', other, self) def __rsub__(self, other): - """Overrides the default subtraction and flavours it with BandMathX""" + """ + Overrides the default subtraction and flavours it with BandMathX + :param other: Another otbObject + :return: other - self + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('-', other, self) def __rmul__(self, other): - """Overrides the default multiplication and flavours it with BandMathX""" + """ + Overrides the default multiplication and flavours it with BandMathX + :param other: Another otbObject + :return: other * self + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('*', other, self) def __rtruediv__(self, other): - """Overrides the default division and flavours it with BandMathX""" + """ + Overrides the default division and flavours it with BandMathX + :param other: Another otbObject + :return: other / self + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return Operation('/', other, self) def __abs__(self): - """Overrides the default abs operator and flavours it with BandMathX""" + """ + Overrides the default abs operator and flavours it with BandMathX + :return: abs(self) + """ return Operation('abs', self) def __ge__(self, other): - """Overrides the default greater or equal and flavours it with BandMathX""" + """ + Overrides the default greater or equal and flavours it with BandMathX + :param other: Another otbObject + :return: self >= other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('>=', self, other) def __le__(self, other): - """Overrides the default less or equal and flavours it with BandMathX""" + """ + Overrides the default less or equal and flavours it with BandMathX + :param other: Another otbObject + :return: self <= other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('<=', self, other) def __gt__(self, other): - """Overrides the default greater operator and flavours it with BandMathX""" + """ + Overrides the default greater operator and flavours it with BandMathX + :param other: Another otbObject + :return: self > other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('>', self, other) def __lt__(self, other): - """Overrides the default less operator and flavours it with BandMathX""" + """ + Overrides the default less operator and flavours it with BandMathX + :param other: Another otbObject + :return: self < other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('<', self, other) def __eq__(self, other): - """Overrides the default eq operator and flavours it with BandMathX""" + """ + Overrides the default eq operator and flavours it with BandMathX + :param other: Another otbObject + :return: self == other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('==', self, other) def __ne__(self, other): - """Overrides the default different operator and flavours it with BandMathX""" + """ + Overrides the default different operator and flavours it with BandMathX + :param other: Another otbObject + :return: self != other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('!=', self, other) def __or__(self, other): - """Overrides the default or operator and flavours it with BandMathX""" + """ + Overrides the default or operator and flavours it with BandMathX + :param other: Another otbObject + :return: self || other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('||', self, other) def __and__(self, other): - """Overrides the default and operator and flavours it with BandMathX""" + """ + Overrides the default and operator and flavours it with BandMathX + :param other: Another otbObject + :return: self && other + """ if isinstance(other, (np.ndarray, np.generic)): return NotImplemented # this enables to fallback on numpy emulation thanks to __array_ufunc__ return logicalOperation('&&', self, other) @@ -229,11 +315,16 @@ class otbObject(ABC): # e.g. __pow__... cf https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types def __hash__(self): + """ + :return: self hash + """ return id(self) def to_numpy(self, propagate_pixel_type=False): """ Export a pyotb object to numpy array + :param propagate_pixel_type: when set to True, the numpy array is created with the same pixel type as + the otbObject first output. Default is False. :return: a numpy array """ if hasattr(self, 'output_parameter_key'): # this is for Input, Output, Operation, Slicer @@ -272,15 +363,15 @@ class otbObject(ABC): # Converting potential pyotb inputs to arrays arrays = [] image_dic = None - for input in inputs: - if isinstance(input, (float, int, np.ndarray, np.generic)): - arrays.append(input) - elif isinstance(input, (App, Input, Output, Operation, Slicer)): + for inp in inputs: + if isinstance(inp, (float, int, np.ndarray, np.generic)): + arrays.append(inp) + elif isinstance(inp, (App, Input, Output, Operation, Slicer)): if hasattr(self, 'output_parameter_key'): # this is for Input, Output, Operation, Slicer output_parameter_key = self.output_parameter_key else: # this is for App output_parameter_key = self.output_parameters_keys[0] - image_dic = input.app.ExportImage(output_parameter_key) + image_dic = inp.app.ExportImage(output_parameter_key) array = image_dic['array'] arrays.append(array) else: @@ -301,30 +392,31 @@ class otbObject(ABC): app.Execute() return app - else: - return NotImplemented + return NotImplemented class Slicer(otbObject): - """Slicer objects i.e. when we call something like raster[:, :, 2] from Python""" + """ + Slicer objects i.e. when we call something like raster[:, :, 2] from Python + """ - def __init__(self, input, rows, cols, channels): + 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#" - :param input: - :param rows: - :param cols: - :param channels: + :param x: input + :param rows: rows slicing (e.g. 100:2000) + :param cols: columns slicing (e.g. 100:2000) + :param channels: channels, can be slicing, list or int """ # Initialize the app that will be used for writing the slicer - app = App('ExtractROI', {"in": input, 'mode': 'extent'}, propagate_pixel_type=True) + app = App('ExtractROI', {"in": x, 'mode': 'extent'}, propagate_pixel_type=True) self.output_parameter_key = 'out' self.name = 'Slicer' # Channel slicing - nb_channels = get_nbchannels(input) + nb_channels = get_nbchannels(x) if channels != slice(None, None, None): # if needed, converting int to list if isinstance(channels, int): @@ -371,35 +463,49 @@ class Slicer(otbObject): # 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 = input + self.input = x class Input(otbObject): """ Class for transforming a filepath to pyOTB object """ + def __init__(self, filepath): + """ + :param filepath: raster file path + """ self.app = App('ExtractROI', filepath, propagate_pixel_type=True).app self.output_parameter_key = 'out' self.filepath = filepath self.name = f'Input from {filepath}' def __str__(self): - return '<pyotb.Input object from {}>'.format(self.filepath) + """ + :return: a str + """ + return f'<pyotb.Input object from {self.filepath}>' class Output(otbObject): """ Class for output of an app """ + def __init__(self, app, output_parameter_key): + """ + :param app: The OTB application + :param 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): - return '<pyotb.Output {} object, id {}>'.format(self.app.GetName(), id(self)) - + """ + :return: a str + """ + return f'<pyotb.Output {self.app.GetName()} object, id {id(self)}>' class App(otbObject): @@ -407,28 +513,42 @@ class App(otbObject): Class of an OTB app """ _name = "" + @property def name(self): - """Property to store a custom app name that will be displayed in logs""" + """ + :return: name or appname + """ return self._name or self.appname @name.setter def name(self, val): + """ + set name + :param val: new name + """ self._name = val @property def finished(self): - """Property to store whether or not App has been executed""" + """ + Property to store whether App has been executed + :return: True or False + """ if self._finished and self.find_output(): return True return False @finished.setter def finished(self, val): - # This will only store if app has been excuted, then find_output() is called when accessing the property + """ + This will only store if app has been executed, then find_output() is called when accessing the property + :param val: new status + """ self._finished = val - def __init__(self, appname, *args, execute=True, image_dic=None, otb_stdout=True, propagate_pixel_type=False, **kwargs): + def __init__(self, appname, *args, execute=True, image_dic=None, otb_stdout=True, propagate_pixel_type=False, + **kwargs): """ Enables to run an otb app as a oneliner. Handles in-memory connection between apps :param appname: name of the app, e.g. 'Smoothing' @@ -460,7 +580,7 @@ class App(otbObject): if args or kwargs: self.set_parameters(*args, **kwargs) else: - logger.warning(f"{self.name}: No parameters where provided. Use App.set_parameters() then App.execute()") + logger.warning("%s: No parameters where provided. Use App.set_parameters() then App.execute()", self.name) execute = False # Run app, write output if needed, update `finished` property if execute: @@ -479,7 +599,7 @@ class App(otbObject): Execute with appropriate App method and outputs to disk if needed" :return: boolean flag that indicate if command executed with success """ - logger.debug(f"{self.name}: run execute() with parameters={self.parameters}") + logger.debug("%s: run execute() with parameters=%s", self.name, self.parameters) try: if self.__with_output(): self.app.ExecuteAndWriteOutput() @@ -487,7 +607,7 @@ class App(otbObject): self.finished = True except (RuntimeError, FileNotFoundError) as e: raise Exception(f'{self.name}: error during during app execution') from e - logger.debug(f"{self.name}: execution succeeded") + logger.debug("%s: execution succeeded", self.name) return self.finished def find_output(self): @@ -496,7 +616,7 @@ class App(otbObject): :return: list of files found on disk """ if not self.__with_output(): - return + return None files = [] missing = [] for param in self.output_parameters_keys: @@ -507,15 +627,15 @@ class App(otbObject): missing.append(filename) if missing: for filename in missing: - logger.error(f"{self.name}: execution seems to have failed, {filename} does not exist") - #raise FileNotFoundError(filename) + logger.error("%s: execution seems to have failed, %s does not exist", self.name, filename) + # raise FileNotFoundError(filename) return files def clear(self, parameters=True, memory=False): """ - Free ressources and reset App state - :param parameters: to clear settings dictionnary - :param memory: to free app ressources in memory + Free resources and reset App state + :param parameters: to clear settings dictionary + :param memory: to free app resources in memory """ if parameters: for p in self.parameters: @@ -524,6 +644,9 @@ class App(otbObject): self.app.FreeRessources() def get_output_parameters_keys(self): + """ + :return: output parameters keys + """ return [param for param in self.app.GetParametersKeys() if self.app.GetParameterType(param) == otb.ParameterType_OutputImage] @@ -582,35 +705,37 @@ class App(otbObject): self.app.ConnectImage(param, obj.app, obj.output_parameter_key) 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] + 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) - ### Parameter list cases + # Parameter list cases # 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 input in obj: - if isinstance(input, App): - self.app.ConnectImage(param, input.app, input.output_parameters_keys[0]) - elif isinstance(input, (Output, Input, Operation, Slicer)): - self.app.ConnectImage(param, input.app, input.output_parameter_key) - elif isinstance(input, otb.Application): # this is for backward comp with plain OTB - outparamkey = [param for param in input.GetParametersKeys() if - input.GetParameterType(param) == otb.ParameterType_OutputImage][0] - self.app.ConnectImage(param, input, outparamkey) + for inp in obj: + if isinstance(inp, App): + self.app.ConnectImage(param, inp.app, inp.output_parameters_keys[0]) + elif isinstance(inp, (Output, Input, Operation, Slicer)): + self.app.ConnectImage(param, inp.app, inp.output_parameter_key) + 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, input) + self.app.AddParameterStringList(param, inp) # List of any other types (str, int...) else: self.app.SetParameterValue(param, obj) def __propagate_pixel_type(self): - """Propagate the pixel type from inputs to output. If several inputs, the type of an arbitrary input - is considered. If several outputs, all outputs will have the same type.""" + """ + Propagate the pixel type from inputs to output. If several inputs, the type of an arbitrary input + is considered. If several outputs, all outputs will have the same type. + """ pixel_type = None for param in self.parameters.values(): try: @@ -618,18 +743,23 @@ class App(otbObject): except TypeError: pass if pixel_type is None: # we use this syntax because pixel_type can be equal to 0 - logger.warning(f"{self.name}: Could not propagate pixel type from inputs to output, " + - f"no valid input found") + logger.warning("%s: Could not propagate pixel type from inputs to output, no valid input found", self.name) else: 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""" - return any([k in self.parameters for k in self.output_parameters_keys]) + """ + Check if App has any output parameter key + :return: True or False + """ + return any(k in self.parameters for k in self.output_parameters_keys) def __is_key_list(self, key): - """Check if a key of the App is an input parameter list""" + """ + Check if a key of the App is an input parameter list + :return: True or False + """ return self.app.GetParameterType(key) in ( otb.ParameterType_InputImageList, otb.ParameterType_StringList, @@ -639,7 +769,11 @@ class App(otbObject): ) def __is_key_images_list(self, key): - """Check if a key of the App is an input parameter image list""" + """ + Check if a key of the App is an input parameter image list + :param key: key + :return: True or False + """ return self.app.GetParameterType(key) in ( otb.ParameterType_InputImageList, otb.ParameterType_InputFilenameList @@ -647,13 +781,17 @@ class App(otbObject): # Special methods def __str__(self): + """ + Return a nice str + """ return f'<pyotb.App {self.appname} object id {id(self)}>' class Operation(otbObject): - """Class for all arithmetic operations. + """ + Class for all arithmetic operations. - Example + Example: ------- Consider the python expression (input1 + 2 * input2) > 0. This class enables to create a BandMathX app, with expression such as (im2 + 2 * im1) > 0 ? 1 : 0 @@ -686,6 +824,11 @@ class Operation(otbObject): # We first create a 'fake' expression. E.g for the operation `input1 + input2` , we create a fake expression # that is like "str(input1) + str(input2)" + self.inputs = [] + self.nb_channels = {} + self.fake_exp_bands = [] + self.logical_fake_exp_bands = [] + self.create_fake_exp(operator, inputs, nb_bands=nb_bands) # Transforming images to the adequate im#, e.g. `input1` to "im1" @@ -694,11 +837,11 @@ class Operation(otbObject): self.im_dic = {} self.im_count = 1 mapping_str_to_input = {} # to be able to retrieve the real python object from its string representation - for input in self.inputs: - if not isinstance(input, (int, float)): - if str(input) not in self.im_dic: - self.im_dic[str(input)] = 'im{}'.format(self.im_count) - mapping_str_to_input[str(input)] = input + for inp in self.inputs: + if not isinstance(inp, (int, float)): + if str(inp) not in self.im_dic: + self.im_dic[str(inp)] = f'im{self.im_count}' + mapping_str_to_input[str(inp)] = inp self.im_count += 1 # getting unique image inputs, in the order im1, im2, im3 ... @@ -723,16 +866,17 @@ class Operation(otbObject): :param inputs: inputs. Can be App, Output, Input, Operation, Slicer, filepath, int or float :param nb_bands: to specify the output nb of bands. Optional """ - self.inputs = [] - self.nb_channels = {} - logger.debug(f"{operator}, {inputs}") + self.inputs.clear() + self.nb_channels.clear() + + logger.debug("%s, %s", operator, inputs) # this is when we use the ternary operator with `pyotb.where` function. The output nb of bands is already known if operator == '?' and nb_bands: - nb_bands = nb_bands + pass # For any other operations, the output number of bands is the same as inputs else: - if any([isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs]): + if any(isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs): nb_bands = 1 else: nb_bands_list = [get_nbchannels(input) for input in inputs if not isinstance(input, (float, int))] @@ -743,23 +887,23 @@ class Operation(otbObject): nb_bands = nb_bands_list[0] # Create a list of fake expressions, each item of the list corresponding to one band - self.fake_exp_bands = [] + self.fake_exp_bands.clear() for i, band in enumerate(range(1, nb_bands + 1)): fake_exps = [] - for k, input in enumerate(inputs): + for k, inp in enumerate(inputs): # Generating the fake expression of the current input # this is a special case for the condition of the ternary operator `cond ? x : y` if len(inputs) == 3 and k == 0: # when cond is monoband whereas the result is multiband, we expand the cond to multiband - if nb_bands != input.shape[2]: + if nb_bands != inp.shape[2]: cond_band = 1 else: cond_band = band - fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(input, cond_band, + fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(inp, cond_band, keep_logical=True) # any other input else: - fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(input, band, + fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(inp, band, keep_logical=False) fake_exps.append(fake_exp) @@ -780,64 +924,71 @@ class Operation(otbObject): self.fake_exp_bands.append(fake_exp) - def _create_one_input_fake_exp(self, input, band, keep_logical=False): + @staticmethod + def _create_one_input_fake_exp(x, band, keep_logical=False): """ This an internal function, only to be used by `create_fake_exp`. Enable to create a fake expression just for one input and one band. - :param input: + :param x: input :param band: which band to consider (bands start at 1) :param keep_logical: whether to keep the logical expressions "as is" in case the input is a logical operation. ex: if True, for `input1 > input2`, returned fake expression is "str(input1) > str(input2)" if False, for `input1 > input2`, returned fake expression is "str(input1) > str(input2) ? 1 : 0" + :return: fake_exp, inputs, nb_channels """ # Special case for one-band slicer - if isinstance(input, Slicer) and hasattr(input, 'one_band_sliced'): - if keep_logical and isinstance(input.input, logicalOperation): - fake_exp = input.input.logical_fake_exp_bands[input.one_band_sliced - 1] - inputs = input.input.inputs - nb_channels = input.input.nb_channels - elif isinstance(input.input, Operation): + if isinstance(x, Slicer) and hasattr(x, 'one_band_sliced'): + if keep_logical and isinstance(x.input, logicalOperation): + fake_exp = x.input.logical_fake_exp_bands[x.one_band_sliced - 1] + inputs = x.input.inputs + nb_channels = x.input.nb_channels + elif isinstance(x.input, Operation): # keep only one band of the expression - fake_exp = input.input.fake_exp_bands[input.one_band_sliced - 1] - inputs = input.input.inputs - nb_channels = input.input.nb_channels + fake_exp = x.input.fake_exp_bands[x.one_band_sliced - 1] + inputs = x.input.inputs + nb_channels = x.input.nb_channels else: # Add the band number (e.g. replace '<pyotb.App object>' by '<pyotb.App object>b1') - fake_exp = str(input.input) + f'b{input.one_band_sliced}' - inputs = [input.input] - nb_channels = {input.input: 1} + fake_exp = str(x.input) + f'b{x.one_band_sliced}' + inputs = [x.input] + nb_channels = {x.input: 1} # For logicalOperation, we save almost the same attributes as an Operation - elif keep_logical and isinstance(input, logicalOperation): - fake_exp = input.logical_fake_exp_bands[band - 1] - inputs = input.inputs - nb_channels = input.nb_channels - elif isinstance(input, Operation): - fake_exp = input.fake_exp_bands[band - 1] - inputs = input.inputs - nb_channels = input.nb_channels + elif keep_logical and isinstance(x, logicalOperation): + fake_exp = x.logical_fake_exp_bands[band - 1] + inputs = x.inputs + nb_channels = x.nb_channels + elif isinstance(x, Operation): + fake_exp = x.fake_exp_bands[band - 1] + inputs = x.inputs + nb_channels = x.nb_channels # For int or float input, we just need to save their value - elif isinstance(input, (int, float)): - fake_exp = str(input) + elif isinstance(x, (int, float)): + fake_exp = str(x) inputs = None nb_channels = None # We go on with other inputs, i.e. pyotb objects, filepaths... else: - nb_channels = {input: get_nbchannels(input)} - inputs = [input] + nb_channels = {x: get_nbchannels(x)} + inputs = [x] # Add the band number (e.g. replace '<pyotb.App object>' by '<pyotb.App object>b1') - fake_exp = str(input) + f'b{band}' + fake_exp = str(x) + f'b{band}' return fake_exp, inputs, nb_channels def get_real_exp(self, fake_exp_bands): - """Generates the BandMathX expression""" + """ + Generates the BandMathX expression + :param fake_exp_bands: list of fake expressions, each item corresponding to one band + :return exp_bands: BandMath expression, split in a list, each item corresponding to one band + :return exp: BandMath expression + """ # Create a list of expression, each item corresponding to one band (e.g. ['im1b1 + 1', 'im1b2 + 1']) exp_bands = [] for one_band_fake_exp in fake_exp_bands: one_band_exp = one_band_fake_exp - for input in self.inputs: + for inp in self.inputs: # replace the name of in-memory object (e.g. '<pyotb.App object>b1' by 'im1b1') - one_band_exp = one_band_exp.replace(str(input), self.im_dic[str(input)]) + one_band_exp = one_band_exp.replace(str(inp), self.im_dic[str(inp)]) exp_bands.append(one_band_exp) # Form the final expression (e.g. 'im1b1 + 1; im1b2 + 1') @@ -846,6 +997,9 @@ class Operation(otbObject): return exp_bands, exp def __str__(self): + """ + :return: a nice str + """ return f'<pyotb.Operation `{self.operator}` object, id {id(self)}>' @@ -857,16 +1011,25 @@ class logicalOperation(Operation): """ def __init__(self, operator, *inputs, nb_bands=None): + """ + operator: Operator + inputs: inputs + nb_bands: number of channels + """ 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): - self.inputs = [] - self.nb_channels = {} + """ + Create a dummy bandmath expression + :param operator: (str) one of >, <, >=, <=, ==, !=, &, | + :param inputs: Can be App, Output, Input, Operation, Slicer, filepath, int or float + :param nb_bands: to specify the output nb of bands. Optional, should only be used for `?` operation + """ # For any other operations, the output number of bands is the same as inputs - if any([isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs]): + if any(isinstance(input, Slicer) and hasattr(input, 'one_band_sliced') for input in inputs): nb_bands = 1 else: nb_bands_list = [get_nbchannels(input) for input in inputs if not isinstance(input, (float, int))] @@ -877,12 +1040,10 @@ class logicalOperation(Operation): nb_bands = nb_bands_list[0] # Create a list of fake exp, each item of the list corresponding to one band - self.fake_exp_bands = [] - self.logical_fake_exp_bands = [] for i, band in enumerate(range(1, nb_bands + 1)): fake_exps = [] - for input in inputs: - fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(input, band, + for inp in inputs: + fake_exp, corresponding_inputs, nb_channels = self._create_one_input_fake_exp(inp, band, keep_logical=True) fake_exps.append(fake_exp) @@ -915,8 +1076,8 @@ def get_nbchannels(inp): try: info = App("ReadImageInfo", inp, otb_stdout=False) nb_channels = info.GetParameterInt("numberbands") - except Exception: # this happens when we pass a str that is not a filepath - raise TypeError(f'Could not get the number of channels of `{inp}`. Not a filepath or wrong filepath') + except Exception as e: # this happens when we pass a str that is not a filepath + raise TypeError(f'Could not get the number of channels of `{inp}`. Not a filepath or wrong filepath') from e return nb_channels @@ -932,8 +1093,8 @@ def get_pixel_type(inp): # Executing the app, without printing its log try: info = App("ReadImageInfo", inp, otb_stdout=False) - except Exception: # this happens when we pass a str that is not a filepath - raise TypeError(f'Could not get the pixel type of `{inp}`. Not a filepath or wrong filepath') + except Exception as info_err: # this happens when we pass a str that is not a filepath + raise TypeError(f'Could not get the pixel type of `{inp}`. Not a filepath or wrong filepath') from info_err datatype = info.GetParameterString("datatype") # which is such as short, float... dataype_to_pixeltype = {'unsigned_char': 'uint8', 'short': 'int16', 'unsigned_short': 'uint16', 'int': 'int32', 'unsigned_int': 'uint32', 'long': 'int32', 'ulong': 'uint32', diff --git a/pyotb/functions.py b/pyotb/functions.py index e3bf12ed432430627cf3d05fafd6f851671abd3a..7523924047ca2b4bffc8804af733ba3ef26e1499 100644 --- a/pyotb/functions.py +++ b/pyotb/functions.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +""" +This module provides a set of functions for pyotb +""" import inspect import os import sys @@ -8,6 +11,7 @@ import logging from collections import Counter from .core import (App, Input, Operation, logicalOperation, get_nbchannels) + logger = logging.getLogger() """ Contains several useful functions base on pyotb @@ -22,7 +26,7 @@ def where(cond, x, y): multiband, cond channels are expanded to match x & y ones. :param x: value if cond is True. Can be float, int, App, filepath, Operation... :param y: value if cond is False. Can be float, int, App, filepath, Operation... - :return: + :return: an output where pixels are x if cond is True, else y """ # Checking the number of bands of rasters. Several cases : # - if cond is monoband, x and y can be multibands. Then cond will adapt to match x and y nb of bands @@ -35,8 +39,8 @@ def where(cond, x, y): if x_nb_channels and y_nb_channels: if x_nb_channels != y_nb_channels: - raise Exception('X and Y images do not have the same number of bands. ' + - f'X has {x_nb_channels} bands whereas Y has {y_nb_channels} bands') + raise ValueError('X and Y images do not have the same number of bands. ' + f'X has {x_nb_channels} bands whereas Y has {y_nb_channels} bands') x_or_y_nb_channels = x_nb_channels if x_nb_channels else y_nb_channels cond_nb_channels = get_nbchannels(cond) @@ -48,13 +52,13 @@ def where(cond, x, y): out_nb_channels = cond_nb_channels if cond_nb_channels != 1 and x_or_y_nb_channels and cond_nb_channels != x_or_y_nb_channels: - raise Exception(f'Condition and X&Y do not have the same number of bands. Condition has ' - '{cond_nb_channels} bands whereas X&Y have {x_or_y_nb_channels} bands') + raise ValueError('Condition and X&Y do not have the same number of bands. Condition has ' + f'{cond_nb_channels} bands whereas X&Y have {x_or_y_nb_channels} bands') # If needed, duplicate the single band binary mask to multiband to match the dimensions of x & y if cond_nb_channels == 1 and x_or_y_nb_channels and x_or_y_nb_channels != 1: - logger.info(f'The condition has one channel whereas X/Y has/have {x_or_y_nb_channels} channels. Expanding number of channels ' - 'of condition to match the number of channels or X/Y') + logger.info('The condition has one channel whereas X/Y has/have %s channels. Expanding number' + ' of channels of condition to match the number of channels of X/Y', x_or_y_nb_channels) operation = Operation('?', cond, x, y, nb_bands=out_nb_channels) @@ -66,7 +70,7 @@ def clip(a, a_min, a_max): Clip values of image in a range of values :param a: input raster, can be filepath or any pyotb object - :param a_min: minumum value of the range + :param a_min: minimum value of the range :param a_max: maximum value of the range :return: raster whose values are clipped in the range """ @@ -78,31 +82,31 @@ def clip(a, a_min, a_max): return res -def all(*inputs): +def all(*inputs): # pylint: disable=redefined-builtin """ For only one image, this function checks that all bands of the image are True (i.e. !=0) and outputs a singleband boolean raster For several images, this function checks that all images are True (i.e. !=0) and outputs a boolean raster, with as many bands as the inputs :param inputs: - :return: + :return: AND intersection """ # Transforming potential filepaths to pyotb objects inputs = [Input(input) if isinstance(input, str) else input for input in inputs] # Checking that all bands of the single image are True if len(inputs) == 1: - input = inputs[0] - if isinstance(input, logicalOperation): - res = input[:, :, 0] + inp = inputs[0] + if isinstance(inp, logicalOperation): + res = inp[:, :, 0] else: - res = (input[:, :, 0] != 0) + res = (inp[:, :, 0] != 0) - for band in range(1, input.shape[-1]): - if isinstance(input, logicalOperation): - res = res & input[:, :, band] + for band in range(1, inp.shape[-1]): + if isinstance(inp, logicalOperation): + res = res & inp[:, :, band] else: - res = res & (input[:, :, band] != 0) + res = res & (inp[:, :, band] != 0) # Checking that all images are True else: @@ -110,40 +114,40 @@ def all(*inputs): res = inputs[0] else: res = (inputs[0] != 0) - for input in inputs[1:]: - if isinstance(input, logicalOperation): - res = res & input + for inp in inputs[1:]: + if isinstance(inp, logicalOperation): + res = res & inp else: - res = res & (input != 0) + res = res & (inp != 0) return res -def any(*inputs): +def any(*inputs): # pylint: disable=redefined-builtin """ For only one image, this function checks that at least one band of the image is True (i.e. !=0) and outputs - a singleband boolean raster + a single band boolean raster For several images, this function checks that at least one of the images is True (i.e. !=0) and outputs a boolean raster, with as many bands as the inputs :param inputs: - :return: + :return: OR intersection """ # Transforming potential filepaths to pyotb objects inputs = [Input(input) if isinstance(input, str) else input for input in inputs] # Checking that at least one band of the image is True if len(inputs) == 1: - input = inputs[0] - if isinstance(input, logicalOperation): - res = input[:, :, 0] + inp = inputs[0] + if isinstance(inp, logicalOperation): + res = inp[:, :, 0] else: - res = (input[:, :, 0] != 0) + res = (inp[:, :, 0] != 0) - for band in range(1, input.shape[-1]): - if isinstance(input, logicalOperation): - res = res | input[:, :, band] + for band in range(1, inp.shape[-1]): + if isinstance(inp, logicalOperation): + res = res | inp[:, :, band] else: - res = res | (input[:, :, band] != 0) + res = res | (inp[:, :, band] != 0) # Checking that at least one image is True else: @@ -151,20 +155,137 @@ def any(*inputs): res = inputs[0] else: res = (inputs[0] != 0) - for input in inputs[1:]: - if isinstance(input, logicalOperation): - res = res | input + for inp in inputs[1:]: + if isinstance(inp, logicalOperation): + res = res | inp else: - res = res | (input != 0) + res = res | (inp != 0) return res +def run_tf_function(func): + """ + This function enables using a function that calls some TF operations, with pyotb object as inputs. + + For example, you can write a function that uses TF operations like this : + @run_tf_function + def multiply(input1, input2): + import tensorflow as tf + return tf.multiply(input1, input2) + + Then you can use it like this : + result = multiply(pyotb_object1, pyotb_object1) # this is a pyotb object + + :param func: function taking one or several inputs and returning *one* output + :return wrapper: a function that returns a pyotb object + """ + try: + from .apps import TensorflowModelServe + except ImportError: + logger.error('Could not run Tensorflow function: failed to import TensorflowModelServe. Check that you ' + 'have OTBTF configured (https://github.com/remicres/otbtf#how-to-install)') + raise + + def get_tf_pycmd(output_dir, channels, scalar_inputs): + """ + Create a string containing all python instructions necessary to create and save the Keras model + + :param output_dir: directory under which to save the model + :param channels: list of raster channels (int). Contain `None` entries for non-raster inputs + :param scalar_inputs: list of scalars (int/float). Contain `None` entries for non-scalar inputs + """ + + # Getting the string definition of the tf function (e.g. "def multiply(x1, x2):...") + # TODO: maybe not entirely foolproof, maybe we should use dill instead? but it would add a dependency + func_def_str = inspect.getsource(func) + func_name = func.__name__ + + create_and_save_model_str = func_def_str + + # Adding the instructions to create the model and save it to output dir + create_and_save_model_str += textwrap.dedent(f""" + import tensorflow as tf + + model_inputs = [] + tf_inputs = [] + for channel, scalar_input in zip({channels}, {scalar_inputs}): + if channel: + input = tf.keras.Input((None, None, channel)) + tf_inputs.append(input) + model_inputs.append(input) + else: + if isinstance(scalar_input, int): # TF doesn't like mixing float and int + scalar_input = float(scalar_input) + tf_inputs.append(scalar_input) + + output = {func_name}(*tf_inputs) + + # Create and save the .pb model + model = tf.keras.Model(inputs=model_inputs, outputs=output) + model.save("{output_dir}") + """) + + return create_and_save_model_str + + def wrapper(*inputs, tmp_dir='/tmp'): + """ + For the user point of view, this function simply applies some TensorFlow operations to some rasters. + Implicitly, it saves a .pb model that describe the TF operations, then creates an OTB ModelServe application + that applies this .pb model to the inputs. + + :param inputs: a list of pyotb objects, filepaths or int/float numbers + :param tmp_dir: directory where temporary models can be written + :return: a pyotb object, output of TensorFlowModelServe + """ + # Get infos about the inputs + channels = [] + scalar_inputs = [] + raster_inputs = [] + for inp in inputs: + try: + # this is for raster input + channel = get_nbchannels(inp) + channels.append(channel) + scalar_inputs.append(None) + raster_inputs.append(inp) + except TypeError: + # this is for other inputs (float, int) + channels.append(None) + scalar_inputs.append(inp) + + # Create and save the model. This is executed **inside an independent process** because (as of 2022-03), + # tensorflow python library and OTBTF are incompatible + out_savedmodel = os.path.join(tmp_dir, f'tmp_otbtf_model_{uuid.uuid4()}') + pycmd = get_tf_pycmd(out_savedmodel, channels, scalar_inputs) + cmd_args = [sys.executable, "-c", pycmd] + try: + import subprocess + subprocess.run(cmd_args, env=os.environ, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + except subprocess.SubprocessError: + logger.debug("Failed to call subprocess") + if not os.path.isdir(out_savedmodel): + logger.info("Failed to save the model") + + # Initialize the OTBTF model serving application + model_serve = TensorflowModelServe({'model.dir': out_savedmodel, 'optim.disabletiling': 'on', + 'model.fullyconv': 'on'}, n_sources=len(raster_inputs), execute=False) + # Set parameters and execute + for i, inp in enumerate(raster_inputs): + model_serve.set_parameters({f'source{i + 1}.il': [inp]}) + model_serve.Execute() + # TODO: handle the deletion of the temporary model ? + + return model_serve + + return wrapper + + def define_processing_area(*args, window_rule='intersection', pixel_size_rule='minimal', interpolator='nn', reference_window_input=None, reference_pixel_size_input=None): """ Given several inputs, this function handles the potential resampling and cropping to same extent. - //!\\ Not fully implemented / tested + WARNING: Not fully implemented / tested :param args: list of raster inputs. Can be str (filepath) or pyotb objects :param window_rule: Can be 'intersection', 'union', 'same_as_input', 'specify' @@ -185,14 +306,14 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m # Getting metadatas of inputs metadatas = {} - for input in inputs: - if isinstance(input, str): # this is for filepaths - metadata = Input(input).GetImageMetaData('out') - elif hasattr(input, 'output_parameter_key'): # this is for Output, Input, Operation - metadata = input.GetImageMetaData(input.output_parameter_key) + 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 = input.GetImageMetaData(input.output_parameters_keys[0]) - metadatas[input] = metadata + metadata = inp.GetImageMetaData(inp.output_parameters_keys[0]) + metadatas[inp] = metadata # Get a metadata of an arbitrary image. This is just to compare later with other images any_metadata = next(iter(metadatas.values())) @@ -212,50 +333,49 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m if window_rule == 'intersection': # The coordinates depend on the orientation of the axis of projection if any_metadata['GeoTransform'][1] >= 0: - ULX = max(metadata['UpperLeftCorner'][0] for metadata in metadatas.values()) - LRX = min(metadata['LowerRightCorner'][0] for metadata in metadatas.values()) + ulx = max(metadata['UpperLeftCorner'][0] for metadata in metadatas.values()) + lrx = min(metadata['LowerRightCorner'][0] for metadata in metadatas.values()) else: - ULX = min(metadata['UpperLeftCorner'][0] for metadata in metadatas.values()) - LRX = max(metadata['LowerRightCorner'][0] for metadata in metadatas.values()) + ulx = min(metadata['UpperLeftCorner'][0] for metadata in metadatas.values()) + lrx = max(metadata['LowerRightCorner'][0] for metadata in metadatas.values()) if any_metadata['GeoTransform'][-1] >= 0: - LRY = min(metadata['LowerRightCorner'][1] for metadata in metadatas.values()) - ULY = max(metadata['UpperLeftCorner'][1] for metadata in metadatas.values()) + lry = min(metadata['LowerRightCorner'][1] for metadata in metadatas.values()) + uly = max(metadata['UpperLeftCorner'][1] for metadata in metadatas.values()) else: - LRY = max(metadata['LowerRightCorner'][1] for metadata in metadatas.values()) - ULY = min(metadata['UpperLeftCorner'][1] for metadata in metadatas.values()) + lry = max(metadata['LowerRightCorner'][1] for metadata in metadatas.values()) + uly = min(metadata['UpperLeftCorner'][1] for metadata in metadatas.values()) elif window_rule == 'same_as_input': - ULX = metadatas[reference_window_input]['UpperLeftCorner'][0] - LRX = metadatas[reference_window_input]['LowerRightCorner'][0] - LRY = metadatas[reference_window_input]['LowerRightCorner'][1] - ULY = metadatas[reference_window_input]['UpperLeftCorner'][1] + ulx = metadatas[reference_window_input]['UpperLeftCorner'][0] + lrx = metadatas[reference_window_input]['LowerRightCorner'][0] + lry = metadatas[reference_window_input]['LowerRightCorner'][1] + uly = metadatas[reference_window_input]['UpperLeftCorner'][1] elif window_rule == 'specify': pass - # TODO : it is when the user explicitely specifies the bounding box -> add some arguments in the function + # TODO : it is when the user explicitly specifies the bounding box -> add some arguments in the function elif window_rule == 'union': pass # TODO : it is when the user wants the final bounding box to be the union of all bounding box # It should replace any 'outside' pixel by some NoData -> add `fillvalue` argument in the function - logger.info(f'Cropping all images to extent Upper Left ({ULX}, {ULY}), Lower Right ({LRX}, {LRY})') + logger.info('Cropping all images to extent Upper Left (%s, %s), Lower Right (%s, %s)', ulx, uly, lrx, lry) # Applying this bounding box to all inputs new_inputs = [] - for input in inputs: + for inp in inputs: try: - new_input = App('ExtractROI', {'in': input, 'mode': 'extent', 'mode.extent.unit': 'phy', - 'mode.extent.ulx': ULX, 'mode.extent.uly': LRY, # bug in OTB <= 7.3 : - 'mode.extent.lrx': LRX, 'mode.extent.lry': ULY}) # ULY/LRY are inverted + new_input = App('ExtractROI', {'in': inp, 'mode': 'extent', 'mode.extent.unit': 'phy', + 'mode.extent.ulx': ulx, 'mode.extent.uly': lry, # bug in OTB <= 7.3 : + 'mode.extent.lrx': lrx, 'mode.extent.lry': uly}) # ULY/LRY are inverted # TODO: OTB 7.4 fixes this bug, how to handle different versions of OTB? new_inputs.append(new_input) # Potentially update the reference inputs for later resampling - if str(input) == str(reference_pixel_size_input): # we use comparison of string because calling '==' - # on pyotb objects underlyingly calls BandMathX application, which is not desirable + if str(inp) == str(reference_pixel_size_input): # we use comparison of string because calling '==' + # on pyotb objects implicitly calls BandMathX application, which is not desirable reference_pixel_size_input = new_input - except Exception as e: - logger.error(e) - logger.error(f'Images may not intersect : {input}') - # TODO: what should we do then? return an empty raster ? fail ? return None ? + except RuntimeError as e: + logger.error('Cannot define the processing area for input %s: %s', inp, e) + raise inputs = new_inputs # Update metadatas @@ -279,18 +399,18 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m reference_input = reference_pixel_size_input elif pixel_size_rule == 'specify': pass - # TODO : when the user explicitely specify the pixel size -> add argument inside the function + # TODO : when the user explicitly specify the pixel size -> add argument inside the function pixel_size = metadatas[reference_input]['GeoTransform'][1] - logger.info(f'Resampling all inputs to resolution : {pixel_size}') + logger.info('Resampling all inputs to resolution: %s', pixel_size) # Perform resampling on inputs that do not comply with the target pixel size new_inputs = [] - for input in inputs: - if metadatas[input]['GeoTransform'][1] != pixel_size: - superimposed = App('Superimpose', inr=reference_input, inm=input, interpolator=interpolator) + for inp in inputs: + if metadatas[inp]['GeoTransform'][1] != pixel_size: + superimposed = App('Superimpose', inr=reference_input, inm=inp, interpolator=interpolator) new_inputs.append(superimposed) else: - new_inputs.append(input) + new_inputs.append(inp) inputs = new_inputs # Update metadatas @@ -299,10 +419,10 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m # Final superimposition to be sure to have the exact same image sizes # Getting the sizes of images image_sizes = {} - for input in inputs: - if isinstance(input, str): - input = Input(input) - image_sizes[input] = input.shape[:2] + for inp in inputs: + if isinstance(inp, str): + inp = Input(inp) + image_sizes[inp] = inp.shape[:2] # Selecting the most frequent image size. It will be used as reference. most_common_image_size, _ = Counter(image_sizes.values()).most_common(1)[0] @@ -310,128 +430,12 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m # Superimposition for images that do not have the same size as the others new_inputs = [] - for input in inputs: - if image_sizes[input] != most_common_image_size: - new_input = App('Superimpose', inr=same_size_images[0], inm=input, interpolator=interpolator) + for inp in inputs: + if image_sizes[inp] != most_common_image_size: + new_input = App('Superimpose', inr=same_size_images[0], inm=inp, interpolator=interpolator) new_inputs.append(new_input) else: - new_inputs.append(input) + new_inputs.append(inp) inputs = new_inputs return inputs - - -def run_tf_function(func): - """ - This function enables using a function that calls some TF operations, with pyotb object as inputs. - - For example, you can write a function that uses TF operations like this : - @run_tf_function - def multiply(input1, input2): - import tensorflow as tf - return tf.multiply(input1, input2) - - Then you can use it like this : - result = multiply(pyotb_object1, pyotb_object1) # this is a pyotb object - - :param func: function taking one or several inputs and returning *one* output - :return wrapper: a function that returns a pyotb object - """ - try: - from .apps import TensorflowModelServe - except ImportError: - raise Exception('Could not run Tensorflow function: failed to import TensorflowModelServe. Check that you ' - 'have OTBTF configured (https://github.com/remicres/otbtf#how-to-install)') - - def get_tf_pycmd(output_dir, channels, scalar_inputs): - """ - Create a string containing all python instructions necessary to create and save the Keras model - - :param output_dir: directory under which to save the model - :param channels: list of raster channels (int). Contain `None` entries for non-raster inputs - :param scalar_inputs: list of scalars (int/float). Contain `None` entries for non-scalar inputs - """ - - # Getting the string definition of the tf function (e.g. "def multiply(x1, x2):...") - # TODO: maybe not entirely foolproof, maybe we should use dill instead? but it would add a dependency - func_def_str = inspect.getsource(func) - func_name = func.__name__ - - create_and_save_model_str = func_def_str - - # Adding the instructions to create the model and save it to output dir - create_and_save_model_str += textwrap.dedent(f""" - import tensorflow as tf - - model_inputs = [] - tf_inputs = [] - for channel, scalar_input in zip({channels}, {scalar_inputs}): - if channel: - input = tf.keras.Input((None, None, channel)) - tf_inputs.append(input) - model_inputs.append(input) - else: - if isinstance(scalar_input, int): # TF doesn't like mixing float and int - scalar_input = float(scalar_input) - tf_inputs.append(scalar_input) - - output = {func_name}(*tf_inputs) - - # Create and save the .pb model - model = tf.keras.Model(inputs=model_inputs, outputs=output) - model.save("{output_dir}") - """) - - return create_and_save_model_str - - def wrapper(*inputs, tmp_dir='/tmp'): - """ - For the user point of view, this function simply applies some TensorFlow operations to some rasters. - Underlyingly, it saves a .pb model that describe the TF operations, then creates an OTB ModelServe application - that applies this .pb model to the inputs. - - :param inputs: a list of pyotb objects, filepaths or int/float numbers - :param tmp_dir: directory where temporary models can be written - :return: a pyotb object, output of TensorFlowModelServe - """ - # Get infos about the inputs - channels = [] - scalar_inputs = [] - raster_inputs = [] - for input in inputs: - try: - # this is for raster input - channel = get_nbchannels(input) - channels.append(channel) - scalar_inputs.append(None) - raster_inputs.append(input) - except Exception: - # this is for other inputs (float, int) - channels.append(None) - scalar_inputs.append(input) - - # Create and save the model. This is executed **inside an independent process** because (as of 2022-03), - # tensorflow python library and OTBTF are incompatible - out_savedmodel = os.path.join(tmp_dir, f'tmp_otbtf_model_{uuid.uuid4()}') - pycmd = get_tf_pycmd(out_savedmodel, channels, scalar_inputs) - cmd_args = [sys.executable, "-c", pycmd] - try: - import subprocess - subprocess.run(cmd_args, env=os.environ, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - except subprocess.SubprocessError: - logger.debug("Failed to call subprocess") - if not os.path.isdir(out_savedmodel): - logger.info("Failed to save the model") - - # Initialize the OTBTF model serving application - model_serve = TensorflowModelServe({'model.dir': out_savedmodel, 'optim.disabletiling': 'on', - 'model.fullyconv': 'on'}, n_sources=len(raster_inputs), execute=False) - # Set parameters and execute - for i, input in enumerate(raster_inputs): - model_serve.set_parameters({f'source{i + 1}.il': [input]}) - model_serve.Execute() - # TODO: handle the deletion of the temporary model ? - - return model_serve - - return wrapper diff --git a/pyotb/tools.py b/pyotb/tools.py index 7e82f850a8671c57fec769aa932d79a1ca8bb0c6..aa67ece7d5e12bbcd9b727520fb4f5620860db2f 100644 --- a/pyotb/tools.py +++ b/pyotb/tools.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +""" +This module provides some helpers to properly initialize pyotb +""" import os import sys import logging @@ -29,7 +32,7 @@ def find_otb(root="", scan=True, scan_userdir=True): # OTB_ROOT env variable is first (allow override default OTB version) prefix = root or os.environ.get("OTB_ROOT") or os.environ.get("OTB_DIR") if prefix: - logger.info(f"Found OTB Python API in {prefix}") + logger.info("Found OTB Python API in %s", prefix) # Add path first in PYTHONPATH otb_api = get_python_api(prefix) sys.path.insert(0, str(otb_api)) @@ -44,7 +47,7 @@ def find_otb(root="", scan=True, scan_userdir=True): return str(prefix), str(apps_path) except ImportError: - logging.critical(f"Can't import OTB with OTB_ROOT={prefix}") + logging.critical("Can't import OTB with OTB_ROOT=%s", prefix) return None, None else: logger.warning("Can't reset otb module with new path. You need to restart Python") @@ -60,15 +63,15 @@ def find_otb(root="", scan=True, scan_userdir=True): # Else search system except ImportError: PYTHONPATH = os.environ.get("PYTHONPATH") - logger.info(f"Failed to import otbApplication with PYTHONPATH={PYTHONPATH}") + logger.info("Failed to import otbApplication with PYTHONPATH=%s", PYTHONPATH) if not scan: return None, None logger.info("Searching for it...") lib_dir = None # Scan user's HOME directory tree (this may take some time) - if scan_userdir : + if scan_userdir: for path in Path().home().glob("**/OTB-*/lib"): - logger.info(f"Found {path.parent}") + logger.info("Found %s", path.parent) lib_dir = path prefix = str(path.parent.absolute()) # Or search possible known locations (system scpecific) @@ -81,7 +84,7 @@ def find_otb(root="", scan=True, scan_userdir=True): path = Path(str_path) if not path.exists(): continue - logger.info(f"Found " + str_path) + logger.info("Found %s", str_path) if not prefix: if path.parent.name == "x86_64-linux-gnu": prefix = path.parent.parent.parent @@ -103,18 +106,19 @@ def find_otb(root="", scan=True, scan_userdir=True): # Try to import one last time before sys.exit (in apps.py) try: sys.path.insert(0, str(otb_api)) - import otbApplication as otb - logger.info(f"Using OTB in {prefix}") + import otbApplication + logger.info("Using OTB in %s", prefix) return prefix, get_apps_path(lib_dir) except ModuleNotFoundError: - logger.critical(f"Unable to find OTB Python bindings", exc_info=1) + logger.critical("Unable to find OTB Python bindings", exc_info=1) return None, None except ImportError as e: - logger.critical(f"An error occured while importing Python API") + logger.critical("An error occurred while importing Python API") if str(e).startswith('libpython3.'): logger.critical("It seems like you need to symlink or recompile OTB SWIG bindings") if sys.platform == "linux": - logger.critical(f"Use 'cd {prefix} ; source otbenv.profile ; ctest -S share/otb/swig/build_wrapping.cmake -VV'") + logger.critical("Use 'cd %s ; source otbenv.profile ; " + "ctest -S share/otb/swig/build_wrapping.cmake -VV'", prefix) return None, None logger.critical("full traceback", exc_info=1) @@ -122,17 +126,27 @@ def find_otb(root="", scan=True, scan_userdir=True): def get_python_api(prefix): + """ + Try to find the python path + :param prefix: prefix + :return: the path if found, else None + """ root = Path(prefix) if root.exists(): otb_api = root / "lib/python" if not otb_api.exists(): otb_api = root / "lib/otb/python" - if not otb_api.exists(): - return - return otb_api.absolute() + if otb_api.exists(): + return otb_api.absolute() + return None def get_lib(otb_module): + """ + Try to find the otb library path + :param otb_module: otb module (otbApplication) + :return: library path + """ lib_dir = Path(otb_module.__file__).parent.parent # OTB .run file if lib_dir.name == "lib": @@ -147,17 +161,27 @@ def get_lib(otb_module): def get_apps_path(lib_dir): + """ + Try to get the otb applications path + :param lib_dir: library path + :return: library path if found, else "" + """ if isinstance(lib_dir, Path) and lib_dir.exists(): - logger.debug(f"Using library from {lib_dir}") + logger.debug("Using library from %s", lib_dir) otb_application_path = lib_dir / "otb/applications" if otb_application_path.exists(): return str(otb_application_path.absolute()) # This should not happen, may be with failed builds ? - logger.debug(f"Library directory found but no 'applications' directory whithin it") - return "" + logger.debug("Library directory found but no 'applications' directory inside") + return "" def set_gdal_vars(root): + """ + Set GDAL environment variables + :param root: root directory + :return: True is succeed, False else + """ if (Path(root) / "share/gdal").exists(): # Local GDAL (OTB Superbuild, .run, .exe) gdal_data = str(Path(root + "/share/gdal")) @@ -167,7 +191,7 @@ def set_gdal_vars(root): gdal_data = "/usr/share/gdal" proj_lib = "/usr/share/proj" else: - logger.warning(f"Can't find GDAL directory with prefix {root}") + logger.warning("Can't find GDAL directory with prefix %s", root) return False # Not sure if SWIG will see these os.environ["LC_NUMERIC"] = "C" diff --git a/tests/Data/Input/QB_MUL_ROI_1000_100.tif b/tests/Data/Input/QB_MUL_ROI_1000_100.tif new file mode 100644 index 0000000000000000000000000000000000000000..ed48fe828f71d4ad8cd0945c35aee1d44d3fcaa2 Binary files /dev/null and b/tests/Data/Input/QB_MUL_ROI_1000_100.tif differ diff --git a/tests/ndvi_test.py b/tests/ndvi_test.py new file mode 100644 index 0000000000000000000000000000000000000000..c17a8c3428ae5aa5c380491b255a5d5f0ec712ec --- /dev/null +++ b/tests/ndvi_test.py @@ -0,0 +1,28 @@ +import pyotb + +filepath = 'Data/Input/QB_MUL_ROI_1000_100.tif' +inp = pyotb.Input(filepath) + +# Compute NDVI with bandmath +ndvi_bandmath = (inp[:, :, -1] - inp[:, :, [2]]) / (inp[:, :, -1] + inp[:, :, [2]]) +assert ndvi_bandmath.exp == '((im1b4 - im1b3) / (im1b4 + im1b3))' +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': 3, '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 - im1b3) / (im1b4 + im1b3)) >= 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)'