diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..d9005f2cc7fc4e65f14ed5518276007c08cf2fd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,152 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/pyotb/__init__.py b/pyotb/__init__.py index 859867a45abca73d79147a5215757e15200dccba..9a5ddf59e73ce59a828c7edb07d11454061171f9 100644 --- a/pyotb/__init__.py +++ b/pyotb/__init__.py @@ -1,7 +1,7 @@ -__version__ = "1.0.2" - -from .core import App, Output, Input, get_nbchannels, get_pixel_type +# -*- coding: utf-8 -*- +__version__ = "1.1" from .apps import * - +from .core import App, Output, Input, get_nbchannels, get_pixel_type from .functions import * +from .tools import logger diff --git a/pyotb/apps.py b/pyotb/apps.py index 423a5305a3e090030c1229295bd47cf75a44ccb3..c0b7019f00fce3984b6509ab569a50a6dc7ee41d 100644 --- a/pyotb/apps.py +++ b/pyotb/apps.py @@ -1,31 +1,88 @@ -import sys -import subprocess -from pyotb.core import App, logger - +# -*- coding: utf-8 -*- """ -This is to enable aliases of Apps, i.e. using apps like `pyotb.AppName(...)` instead of `pyotb.App('AppName', ...)` +Search for OTB (set env if necessary), subclass core.App for each available application """ +import os +import sys +from pathlib import Path + +from .tools import logger, find_otb, set_gdal_vars + +# Will first call `import otbApplication`, trying to workaround any ImportError +OTB_ROOT, OTB_APPLICATION_PATH = find_otb() + +if not OTB_ROOT: + sys.exit("Can't run without OTB. Exiting.") + +set_gdal_vars(OTB_ROOT) + +# Should not raise ImportError since it was tested in find_otb() +import otbApplication as otb + -AVAILABLE_APPLICATIONS = None -# Currently there is an incompatibility between OTBTF and Tensorflow that causes segfault when OTB is used in a script -# where tensorflow has been imported. -# Thus, we run this piece of code in a clean independent `subprocess` that doesn't interact with Tensorflow -if sys.executable: - try: - p = subprocess.run([sys.executable, '-c', 'import otbApplication; ' - 'print(otbApplication.Registry.GetAvailableApplications())'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - AVAILABLE_APPLICATIONS = eval(p.stdout.decode().strip()) - except Exception as e: - logger.warning('Failed to get the list of applications in an independent process. Trying to get it inside' - 'the script scope') - -# In case the previous has failed, we try the "normal" way to get the list of applications -if not AVAILABLE_APPLICATIONS or not isinstance(AVAILABLE_APPLICATIONS, (list, tuple)): - import otbApplication - AVAILABLE_APPLICATIONS = otbApplication.Registry.GetAvailableApplications() - -# This is to enable aliases of Apps, i.e. using apps like `pyotb.AppName(...)` instead of `pyotb.App('AppName', ...)` -if AVAILABLE_APPLICATIONS: - for app_name in AVAILABLE_APPLICATIONS: - exec(f"""def {app_name}(*args, **kwargs): return App('{app_name}', *args, **kwargs)""") +def get_available_applications(as_subprocess=False): + """ + Find available OTB applications + :param as_subprocess: indicate if function should list available applications using subprocess call + :returns: tuple of available applications + """ + app_list = () + if as_subprocess and sys.executable and hasattr(sys, 'ps1'): + # 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 + env = os.environ.copy() + 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 + 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}'") + stdout, stderr = p.communicate() + stdout, stderr = stdout.decode(), stderr.decode() + # ast.literal_eval is secure and will raise more handy Exceptions than eval + from ast import literal_eval + app_list = literal_eval(stdout.strip()) + assert isinstance(app_list, (tuple, list)) + + 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}") + if not app_list: + logger.info("Failed to list applications in an independent process. Falling back to local otb import") + + if not app_list: + app_list = otb.Registry.GetAvailableApplications() + if not app_list: + 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") + return app_list + + +if OTB_APPLICATION_PATH: + otb.Registry.SetApplicationPath(OTB_APPLICATION_PATH) + os.environ["OTB_APPLICATION_PATH"] = OTB_APPLICATION_PATH + +AVAILABLE_APPLICATIONS = get_available_applications(as_subprocess=True) + +# First core.py call (within __init__ scope) +from .core import App + +# This is to enable aliases of Apps, i.e. using apps like `pyotb.AppName(...)` instead of `pyotb.App("AppName", ...)` +_code_template = """ +class {name}(App): + def __init__(self, *args, **kwargs): + super().__init__('{name}', *args, **kwargs) +""" +# Here we could customize the template and overwrite special methods depending on application +for _app in AVAILABLE_APPLICATIONS: + exec(_code_template.format(name=_app)) diff --git a/pyotb/core.py b/pyotb/core.py index 19b582b8282a43eda9a4bca8f8b855af8d7b92d7..c8c1ec80a8b8211947cef705b38d55e264d49520 100644 --- a/pyotb/core.py +++ b/pyotb/core.py @@ -1,14 +1,12 @@ -import otbApplication +# -*- coding: utf-8 -*- import logging -import sys -import os from abc import ABC +from pathlib import Path + import numpy as np +import otbApplication as otb -logger = logging -logger.basicConfig(format='%(asctime)s %(levelname)-8s %(message)s', - level=logging.INFO, - datefmt='%Y-%m-%d %H:%M:%S') +logger = logging.getLogger() class otbObject(ABC): @@ -17,28 +15,6 @@ class otbObject(ABC): All child of this class must have an `app` attribute that is an OTB application. """ - - def __getitem__(self, key): - """ - This function enables 2 things : - - access attributes like that : object['any_attribute'] - - 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] - """ - # 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 - key = key + (slice(None, None, None),) - (rows, cols, channels) = key - return Slicer(self, rows, cols, channels) - @property def shape(self): """ @@ -48,18 +24,12 @@ class otbObject(ABC): 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.get_output_parameters_keys()[0] + output_parameter_key = self.output_parameters_keys[0] image_size = self.GetImageSize(output_parameter_key) image_bands = self.GetImageNbBands(output_parameter_key) # TODO: it currently returns (width, height, bands), should we use numpy convention (height, width, bands) ? return (*image_size, image_bands) - 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""" - return getattr(self.app, name) - def write(self, *args, filename_extension=None, pixel_type=None, is_intermediate=False, **kwargs): """ Write the output @@ -79,30 +49,29 @@ class otbObject(ABC): """ # Gather all input arguments in kwargs dict - if args: - for arg in args: - if isinstance(arg, dict): - kwargs.update(arg) - elif isinstance(arg, str) and kwargs: - logger.warning('Keyword arguments specified, ignoring argument: {}'.format(arg)) - elif isinstance(arg, str): - if hasattr(self, 'output_parameter_key'): # this is for Output, Operation - output_parameter_key = self.output_parameter_key - else: # this is for App - output_parameter_key = self.get_output_parameters_keys()[0] - kwargs.update({output_parameter_key: arg}) + for arg in args: + if isinstance(arg, dict): + kwargs.update(arg) + elif isinstance(arg, str) and kwargs: + logger.warning(f'{self.name}: Keyword arguments specified, ignoring argument "{arg}"') + elif isinstance(arg, str): + if hasattr(self, 'output_parameter_key'): # this is for Output, Operation + output_parameter_key = self.output_parameter_key + else: # this is for App + output_parameter_key = self.output_parameters_keys[0] + kwargs.update({output_parameter_key: arg}) # Handling pixel types pixel_types = {} if isinstance(pixel_type, str): # this correspond to 'uint8' etc... - pixel_type = getattr(otbApplication, 'ImagePixelType_{}'.format(pixel_type)) + pixel_type = getattr(otb, f'ImagePixelType_{pixel_type}') pixel_types = {param_key: pixel_type for param_key in kwargs} elif isinstance(pixel_type, int): # this corresponds to ImagePixelType_... pixel_types = {param_key: pixel_type for param_key in kwargs} elif isinstance(pixel_type, dict): # this is to specify a different pixel type for each output for key, this_pixel_type in pixel_type.items(): if isinstance(this_pixel_type, str): - this_pixel_type = getattr(otbApplication, 'ImagePixelType_{}'.format(this_pixel_type)) + this_pixel_type = getattr(otb, f'ImagePixelType_{this_pixel_type}') if isinstance(this_pixel_type, int): pixel_types[key] = this_pixel_type @@ -117,8 +86,8 @@ class otbObject(ABC): if not out_fn.endswith('?'): out_fn += "?" out_fn += filename_extension - logger.info("Using extended filename for output.") - logger.info("Write output for \"{}\" in {}".format(output_parameter_key, out_fn)) + logger.info(f'{self.name}: Using extended filename for output.') + logger.debug(f'{self.name}: write output file "{output_parameter_key}" to {out_fn}') self.app.SetParameterString(output_parameter_key, out_fn) if output_parameter_key in pixel_types: @@ -127,6 +96,36 @@ class otbObject(ABC): self.app.ExecuteAndWriteOutput() self.app.Execute() # this is just to be able to use the object in in-memory pipelines without problems + # Special methods + def __getitem__(self, key): + """ + This function enables 2 things : + - access attributes like that : object['any_attribute'] + - 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] + """ + # 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 + key = key + (slice(None, None, None),) + (rows, cols, 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 + """ + return getattr(self.app, name) + def __add__(self, other): """Overrides the default addition and flavours it with BandMathX""" if isinstance(other, (np.ndarray, np.generic)): @@ -323,8 +322,7 @@ class Slicer(otbObject): elif isinstance(channels, tuple): channels = list(channels) elif not isinstance(channels, list): - raise ValueError( - 'Invalid type for channels, should be int, slice or list of bands. : {}'.format(channels)) + 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] @@ -362,7 +360,6 @@ class Input(otbObject): """ Class for transforming a filepath to pyOTB object """ - def __init__(self, filepath): self.app = App('ExtractROI', filepath).app self.output_parameter_key = 'out' @@ -376,7 +373,6 @@ class Output(otbObject): """ Class for output of an app """ - def __init__(self, app, output_parameter_key): self.app = app # keeping a reference of the app self.output_parameter_key = output_parameter_key @@ -385,12 +381,34 @@ class Output(otbObject): return '<pyotb.Output {} object, id {}>'.format(self.app.GetName(), id(self)) + 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 self._name or self.appname + + @name.setter + def name(self, val): + self._name = val - def __init__(self, appname, *args, execute=True, image_dic=None, **kwargs): + @property + def finished(self): + """Property to store whether or not App has been executed""" + 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 + self._finished = val + + def __init__(self, appname, *args, execute=True, image_dic=None, otb_stdout=True, **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' @@ -407,20 +425,82 @@ class App(otbObject): :param kwargs: keyword arguments e.g. il=['input1.tif', App_object2, App_object3.out], out='output.tif' """ self.appname = appname - self.app = otbApplication.Registry.CreateApplication(appname) + if otb_stdout: + self.app = otb.Registry.CreateApplication(appname) + else: + self.app = otb.Registry.CreateApplicationWithoutLogger(appname) + self.image_dic = image_dic + self._finished = False + # Parameters + self.parameters = {} self.output_parameters_keys = self.get_output_parameters_keys() - self.set_parameters(*args, **kwargs) + 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()") + execute = False + # Run app, write output if needed, update `finished` property if execute: - self.app.Execute() - if image_dic: - self.image_dic = image_dic - + self.execute() # 'Saving' outputs as attributes, i.e. so that they can be accessed like that: App.out # Also, thanks to __getitem__ method, the outputs can be accessed as App["out"]. This is useful when the key # contains reserved characters such as a point eg "io.out" - for output_param_key in self.output_parameters_keys: - output = Output(self.app, output_param_key) - setattr(self, output_param_key, output) + for key in self.output_parameters_keys: + output = Output(self.app, key) + setattr(self, key, output) + + def execute(self): + """ + 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}") + try: + if self.__with_output(): + self.app.ExecuteAndWriteOutput() + self.app.Execute() + 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") + return self.finished + + def find_output(self): + """ + Find output files in directory using parameters + :return: list of files found on disk + """ + if not self.__with_output(): + return + files = [] + missing = [] + for param in self.output_parameters_keys: + filename = self.parameters[param] + if Path(filename).exists(): + files.append(filename) + else: + 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) + 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 + """ + if parameters: + for p in self.parameters: + self.app.ClearValue(p) + if memory: + self.app.FreeRessources() + + def get_output_parameters_keys(self): + return [param for param in self.app.GetParametersKeys() + if self.app.GetParameterType(param) == otb.ParameterType_OutputImage] def set_parameters(self, *args, **kwargs): """ @@ -431,86 +511,100 @@ class App(otbObject): - 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" :param kwargs: keyword arguments e.g. il=['input1.tif', oApp_object2, App_object3.out], out='output.tif' - :return: + :return: boolean flag that indicate if app was correctly set using given parameters """ - if args: - for arg in args: - if isinstance(arg, dict): - kwargs.update(arg) - elif isinstance(arg, (str, App, Output, Input, Operation)): - kwargs.update({'in': arg}) - elif isinstance(arg, list): - kwargs.update({'il': arg}) - + self.parameters.update(self.__parse_args(args)) + self.parameters.update(kwargs) # Going through all arguments - for k, v in kwargs.items(): + for param, obj in self.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(k) and not isinstance(v, (list, tuple)): - v = [v] - - # Single-parameter cases - if isinstance(v, App): - self.app.ConnectImage(k, v.app, v.output_parameters_keys[0]) - elif isinstance(v, (Output, Input, Operation)): - self.app.ConnectImage(k, v.app, v.output_parameter_key) - elif isinstance(v, otbApplication.Application): - outparamkey = [param for param in v.GetParametersKeys() - if v.GetParameterType(param) == otbApplication.ParameterType_OutputImage][0] - self.app.ConnectImage(k, v, outparamkey) - elif k == 'ram': # SetParameterValue in OTB<7.4 doesn't work for ram parameter cf gitlab OTB issue 2200 - self.app.SetParameterInt('ram', int(v)) - elif not isinstance(v, list): # any other parameters (str, int...) - self.app.SetParameterValue(k, v) - - # Parameter list cases - else: - # Images list - if self.is_key_images_list(k): - # To enable possible in-memory connections, we go through the list and set the parameters one by one - for input in v: - print(input) - if isinstance(input, App): - self.app.ConnectImage(k, input.app, input.output_parameters_keys[0]) - elif isinstance(input, (Output, Input, Operation, Slicer)): - self.app.ConnectImage(k, input.app, input.output_parameter_key) - elif isinstance(input, otbApplication.Application): # this is for backward comp with plain OTB - outparamkey = [param for param in input.GetParametersKeys() if - input.GetParameterType(param) == otbApplication.ParameterType_OutputImage][0] - self.app.ConnectImage(k, input, 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(k, input) - - # List of any other types (str, int...) - else: - self.app.SetParameterValue(k, v) - - # Writing outputs to disk if needed - if any([output_param_key in kwargs for output_param_key in self.output_parameters_keys]): - self.app.ExecuteAndWriteOutput() - self.app.Execute() # this is just to be able to use the object in in-memory pipelines without problems - - def get_output_parameters_keys(self): - """ - :return: list of output parameters keys, e.g ['out'] - """ - output_param_keys = [param for param in self.app.GetParametersKeys() - if self.app.GetParameterType(param) == otbApplication.ParameterType_OutputImage] - return output_param_keys - - def is_key_list(self, key): - return ((self.app.GetParameterType(key) == otbApplication.ParameterType_InputImageList) or - (self.app.GetParameterType(key) == otbApplication.ParameterType_StringList) or - (self.app.GetParameterType(key) == otbApplication.ParameterType_InputFilenameList) or - (self.app.GetParameterType(key) == otbApplication.ParameterType_InputVectorDataList) or - (self.app.GetParameterType(key) == otbApplication.ParameterType_ListView)) - - def is_key_images_list(self, key): - return ((self.app.GetParameterType(key) == otbApplication.ParameterType_InputImageList) or - (self.app.GetParameterType(key) == otbApplication.ParameterType_InputFilenameList)) - + if self.__is_key_list(param) and not isinstance(obj, (list, tuple)): + self.parameters[param] = [obj] + obj = [obj] + try: + # This is when we actually call self.app.SetParameter* + self.__set_param(param, obj) + except (RuntimeError, TypeError, ValueError) as e: + raise Exception(f"{self.name}: something went wrong before execution " + f"(while setting parameter {param} to '{obj}')") from e + + # Private functions + @staticmethod + def __parse_args(args): + """Gather all input arguments in kwargs dict""" + kwargs = {} + for arg in args: + if isinstance(arg, dict): + kwargs.update(arg) + elif isinstance(arg, (str, App, Output, Input, Operation, Slicer)): + kwargs.update({'in': arg}) + elif isinstance(arg, list): + kwargs.update({'il': arg}) + return kwargs + + def __set_param(self, param, obj): + """Decide which otb.Application method to use depending on target object""" + # Single-parameter cases + if isinstance(obj, App): + self.app.ConnectImage(param, obj.app, obj.output_parameters_keys[0]) + elif isinstance(obj, (Output, Input, Operation)): + 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] + 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 + # 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) + 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) + # List of any other types (str, int...) + else: + self.app.SetParameterValue(param, obj) + + + 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]) + + def __is_key_list(self, key): + """Check if a key of the App is an input parameter list""" + return self.app.GetParameterType(key) in ( + otb.ParameterType_InputImageList, + otb.ParameterType_StringList, + otb.ParameterType_InputFilenameList, + otb.ParameterType_InputVectorDataList, + otb.ParameterType_ListView + ) + + 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 + ) + + # Special methods def __str__(self): - return '<pyotb.App {} object id {}>'.format(self.appname, id(self)) + return f'<pyotb.App {self.appname} object id {id(self)}>' class Operation(otbObject): @@ -587,7 +681,7 @@ class Operation(otbObject): self.inputs = [] self.nb_channels = {} - print(operator, inputs) + logger.debug(f"{operator}, {inputs}") if operator == '?' and nb_bands: # this is when we use the ternary operator with `pyotb.where` function nb_bands = nb_bands else: @@ -704,10 +798,8 @@ class Operation(otbObject): def __str__(self): if self.input2 is not None: - return '<pyotb.Operation object, {} {} {}, id {}>'.format(str(self.input1), self.operator, str(self.input2), - id(self)) - else: - return '<pyotb.Operation object, {} {}, id {}>'.format(self.operator, str(self.input1), id(self)) + return f'<pyotb.Operation object, {self.input1} {self.operator} {self.input2}, id {id(self)}>' + return f'<pyotb.Operation object, {self.operator} {self.input1}, id {id(self)}>' class logicalOperation(Operation): @@ -718,7 +810,7 @@ class logicalOperation(Operation): """ def __init__(self, operator, *inputs, nb_bands=None): - super().__init__(operator, *inputs) + super().__init__(operator, *inputs, nb_bands=nb_bands) self.logical_exp_bands, self.logical_exp = self.get_real_exp(self.logical_fake_exp_bands) @@ -766,23 +858,19 @@ def get_nbchannels(inp): """ Get the nb of bands of input image :param inp: a str + :return: number of bands in image """ if isinstance(inp, otbObject): nb_channels = inp.shape[-1] else: # Executing the app, without printing its log - stdout = sys.stdout - sys.stdout = open(os.devnull, 'w') try: - info = App("ReadImageInfo", inp) - sys.stdout = stdout + info = App("ReadImageInfo", inp, otb_stdout=False) nb_channels = info.GetParameterInt("numberbands") - except Exception as e: - sys.stdout = stdout - logger.error('Not a valid image : {}'.format(inp)) - logger.error(e) + except (RuntimeError, TypeError) as e: + logger.error(f'Not a valid image : {inp}') + logger.error(f'{e}') nb_channels = None - sys.stdout = stdout return nb_channels @@ -795,16 +883,15 @@ def get_pixel_type(inp): """ if isinstance(inp, str): # Executing the app, without printing its log - stdout = sys.stdout - sys.stdout = open(os.devnull, 'w') - info = App("ReadImageInfo", inp) - sys.stdout = stdout + info = App("ReadImageInfo", inp, otb_stdout=False) datatype = info.GetParameterInt("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', 'float': 'float', - 'double': 'double'} + dataype_to_pixeltype = { + 'unsigned_char': 'uint8', 'short': 'int16', 'unsigned_short': 'uint16', + 'int': 'int32', 'unsigned_int': 'uint32', 'long': 'int32', 'ulong': 'uint32', + 'float': 'float','double': 'double' + } pixel_type = dataype_to_pixeltype[datatype] - pixel_type = getattr(otbApplication, 'ImagePixelType_{}'.format(pixel_type)) + pixel_type = getattr(otb, f'ImagePixelType_{pixel_type}') elif isinstance(inp, (Input, Output, Operation)): pixel_type = inp.GetImageBasePixelType(inp.output_parameter_key) elif isinstance(inp, App): diff --git a/pyotb/functions.py b/pyotb/functions.py index 435674ab1c1a2b641f8aa8b55ff22326c26fc642..6ad8bf224382d4c504bf372e80686f362435cad6 100644 --- a/pyotb/functions.py +++ b/pyotb/functions.py @@ -1,10 +1,12 @@ +# -*- coding: utf-8 -*- import os import uuid +import logging import multiprocessing from collections import Counter -from pyotb.core import (App, Input, Operation, logicalOperation, get_nbchannels, logger) - +from .core import (App, Input, Operation, logicalOperation, get_nbchannels) +logger = logging.getLogger() """ Contains several useful functions base on pyotb """ @@ -31,8 +33,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. ' - 'X has {} bands whereas Y has {} bands'.format(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') x_or_y_nb_channels = x_nb_channels if x_nb_channels else y_nb_channels cond_nb_channels = get_nbchannels(cond) @@ -44,13 +46,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('Condition and X&Y do not have the same number of bands. Condition has ' - '{} bands whereas X&Y have {} bands'.format(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') # 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('The condition has one channel whereas X/Y has/have {} channels. Expanding number of channels ' - 'of condition to match the number of channels or X/Y'.format(x_or_y_nb_channels)) + 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') operation = Operation('?', cond, x, y, nb_bands=out_nb_channels) @@ -233,8 +235,7 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m # 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( - 'Cropping all images to extent Upper Left ({}, {}), Lower Right ({}, {}) '.format(ULX, ULY, LRX, LRY)) + logger.info(f'Cropping all images to extent Upper Left ({ULX}, {ULY}), Lower Right ({LRX}, {LRY})') # Applying this bounding box to all inputs new_inputs = [] @@ -251,7 +252,7 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m reference_pixel_size_input = new_input except Exception as e: logger.error(e) - logger.error('Images may not intersect : {}'.format(input)) + logger.error(f'Images may not intersect : {input}') # TODO: what should we do then? return an empty raster ? fail ? return None ? inputs = new_inputs @@ -277,13 +278,13 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m elif pixel_size_rule == 'specify': pass # TODO : when the user explicitely specify the pixel size -> add argument inside the function - - logger.info('Resampling all inputs to resolution : {}'.format(metadatas[reference_input]['GeoTransform'][1])) + pixel_size = metadatas[reference_input]['GeoTransform'][1] + logger.info(f'Resampling all inputs to resolution : {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] != metadatas[reference_input]['GeoTransform'][1]: + if metadatas[input]['GeoTransform'][1] != pixel_size: superimposed = App('Superimpose', inr=reference_input, inm=input, interpolator=interpolator) new_inputs.append(superimposed) else: @@ -378,7 +379,7 @@ def run_tf_function(func): # Create and save the model. This is executed **inside an independent process** because (as of 2021-11), # tensorflow python library and OTBTF are incompatible - out_savedmodel = os.path.join(tmp_dir, 'tmp_otbtf_model_{}'.format(uuid.uuid4())) + out_savedmodel = os.path.join(tmp_dir, f'tmp_otbtf_model_{uuid.uuid4()}') p = multiprocessing.Process(target=create_and_save_tf_model, args=(out_savedmodel, *inputs,)) p.start() p.join() @@ -394,7 +395,7 @@ def run_tf_function(func): 'optim.disabletiling': 'on', 'model.fullyconv': 'on'}, execute=False) for i, input in enumerate(raster_inputs): - model_serve.set_parameters({'source{}.il'.format(i + 1): [input]}) + model_serve.set_parameters({f'source{i + 1}.il': [input]}) model_serve.Execute() # TODO: handle the deletion of the temporary model ? diff --git a/pyotb/tools.py b/pyotb/tools.py new file mode 100644 index 0000000000000000000000000000000000000000..7e82f850a8671c57fec769aa932d79a1ca8bb0c6 --- /dev/null +++ b/pyotb/tools.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +import os +import sys +import logging +from pathlib import Path + +# This will prevent erasing user config +if "logger" not in globals(): + logging.basicConfig( + format="%(asctime)s (%(levelname)-4s) [pyOTB] %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S", + ) +logger = logging.getLogger() + + +def find_otb(root="", scan=True, scan_userdir=True): + """ + Try to load OTB bindings or scan system, help user in case of failure, return env variables + Precedence : OTB_ROOT > python bindings directory + OR search for releases installations : current directory > home directory + OR (for linux) : /opt/otbtf > /opt/otb > /usr/local > /usr + :param root: prefix to search OTB in + :param scan: find otb in system known locations + :param scan_userdir: search for OTB release in user's home directory + :return: (OTB_ROOT, OTB_APPLICATIONS_PATH) environment variables + """ + + # 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}") + # Add path first in PYTHONPATH + otb_api = get_python_api(prefix) + sys.path.insert(0, str(otb_api)) + if "otb" not in globals(): + try: + import otbApplication + lib_dir = get_lib(otbApplication) + apps_path = get_apps_path(lib_dir) + prefix = lib_dir.parent + if prefix.name == 'lib': + prefix = prefix.parent + return str(prefix), str(apps_path) + + except ImportError: + logging.critical(f"Can't import OTB with OTB_ROOT={prefix}") + return None, None + else: + logger.warning("Can't reset otb module with new path. You need to restart Python") + + # Else try with python file location + try: + import otbApplication + lib_dir = get_lib(otbApplication) + apps_path = get_apps_path(lib_dir) + logger.debug("Found OTB directory using Python lib location") + return str(lib_dir.parent), str(apps_path) + + # Else search system + except ImportError: + PYTHONPATH = os.environ.get("PYTHONPATH") + logger.info(f"Failed to import otbApplication with PYTHONPATH={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 : + for path in Path().home().glob("**/OTB-*/lib"): + logger.info(f"Found {path.parent}") + lib_dir = path + prefix = str(path.parent.absolute()) + # Or search possible known locations (system scpecific) + if sys.platform == "linux": + possible_locations = ( + "/usr/lib/x86_64-linux-gnu/otb", "/usr/local/lib/otb/", + "/opt/otb/lib/otb/", "/opt/otbtf/lib/otb", + ) + for str_path in possible_locations: + path = Path(str_path) + if not path.exists(): + continue + logger.info(f"Found " + str_path) + if not prefix: + if path.parent.name == "x86_64-linux-gnu": + prefix = path.parent.parent.parent + else: + prefix = path.parent.parent + prefix = str(prefix.absolute()) + lib_dir = path.parent.absolute() + else: + # TODO: find OTB path in other OS ? pathlib should help + pass + + # Found OTB + if prefix and lib_dir is not None: + otb_api = get_python_api(prefix) + if otb_api is None or not otb_api.exists(): + logger.error("Can't find OTB python API") + return None, None + + # 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}") + return prefix, get_apps_path(lib_dir) + except ModuleNotFoundError: + logger.critical(f"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") + 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'") + return None, None + logger.critical("full traceback", exc_info=1) + + return None, None + + +def get_python_api(prefix): + 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() + + +def get_lib(otb_module): + lib_dir = Path(otb_module.__file__).parent.parent + # OTB .run file + if lib_dir.name == "lib": + return lib_dir.absolute() + # Case /usr + lib_dir = lib_dir.parent + if lib_dir.name in ("lib", "x86_64-linux-gnu"): + return lib_dir.absolute() + # Case built from source (/usr/local, /opt/otb, ...) + lib_dir = lib_dir.parent + return lib_dir.absolute() + + +def get_apps_path(lib_dir): + if isinstance(lib_dir, Path) and lib_dir.exists(): + logger.debug(f"Using library from {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 "" + + +def set_gdal_vars(root): + if (Path(root) / "share/gdal").exists(): + # Local GDAL (OTB Superbuild, .run, .exe) + gdal_data = str(Path(root + "/share/gdal")) + proj_lib = str(Path(root + "/share/proj")) + elif sys.platform == "linux": + # If installed using apt or built from source with system deps + gdal_data = "/usr/share/gdal" + proj_lib = "/usr/share/proj" + else: + logger.warning(f"Can't find GDAL directory with prefix {root}") + return False + # Not sure if SWIG will see these + os.environ["LC_NUMERIC"] = "C" + os.environ["GDAL_DATA"] = gdal_data + os.environ["PROJ_LIB"] = proj_lib + return True diff --git a/pyproject.toml b/pyproject.toml index 374b58cbf4636f1e28bacf987ac2fe89ed27ccba..ddb1465f3a361addd19954bd24367980b66ec754 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [build-system] requires = [ "setuptools>=42", - "wheel" + "wheel", + "numpy>=1.13,<1.23" ] build-backend = "setuptools.build_meta" diff --git a/scripts/replace_fstrings.py b/scripts/replace_fstrings.py new file mode 100755 index 0000000000000000000000000000000000000000..b3568507f5740b5d67989f70c36691fa5308a309 --- /dev/null +++ b/scripts/replace_fstrings.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import re +import sys + +assert sys.version_info.minor >= 6 + + +def replace_fstrings(py_file, out_file): + """ + Replace fstrings with .format() method + :param py_file: path for input python file to convert + :param out_file: converted file output path + """ + + def replace(line): + exp = re.compile(r"""(.*[\s\(\[{=])f(['"])(.*)(['"])(.*)\n""") + res = re.match(exp, line) + if not res: + return line + prefix, qt1, string, qt2, suffix = res.groups() + args = [] + for part in string.split('{')[1:]: + a = part.split('}')[0] + string = string.replace(a, '') + args.append(a) + + return f"""{prefix}{qt1}{string}{qt2}.format({', '.join(args)}){suffix}\n""" + + with open(py_file, 'r') as r: + lines = list(map(replace, r.readlines())) + with open(out_file, 'w') as w: + for li in lines: + w.write(li) + +if __name__ == "__main__": + replace_fstrings(sys.argv[1], sys.argv[2]) diff --git a/setup.py b/setup.py index f320aaff32e3a14c04a6642189d23451e81f81b2..89e949576a620bd0307a8237373571db0566ac03 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import setuptools with open("README.md", "r", encoding="utf-8") as fh: @@ -5,7 +6,7 @@ with open("README.md", "r", encoding="utf-8") as fh: setuptools.setup( name="pyotb", - version="1.0.2", + version="1.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",