diff --git a/.gitignore b/.gitignore
index 6a8fbea7b1db968e5d15ebe71ceca7a0697a3b77..5abba97894db9526bdfbbdbbae25497a997a275b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -144,11 +144,10 @@ dmypy.json
 # Cython debug symbols
 cython_debug/
 
+# VSCode
+.vscode
+
 # 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/
 
 # VSCode
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 203b789ac2485afeb0b65490aaad3be1a05dd177..4c01b7b3e6185bcb549f772b64840dd4e44f1e9c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -45,7 +45,7 @@ pylint:
   before_script:
     - pip install pylint
   script:
-    - pylint --max-line-length=120 $PWD/pyotb --disable=too-many-nested-blocks,too-many-locals,too-many-statements,too-few-public-methods,too-many-instance-attributes,too-many-arguments,invalid-name,fixme,too-many-return-statements,too-many-lines,too-many-branches,import-outside-toplevel,wrong-import-position,wrong-import-order,import-error,missing-class-docstring
+    - 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,no-member,no-name-in-module
 
 # ---------------------------------- Documentation  ----------------------------------
 
diff --git a/pyotb/__init__.py b/pyotb/__init__.py
index 187b64a3eab5232464472d46eeca1e1f0ca9f161..634ae623294eb465c6469d3f24c6cfd6240b7932 100644
--- a/pyotb/__init__.py
+++ b/pyotb/__init__.py
@@ -1,8 +1,24 @@
 # -*- coding: utf-8 -*-
 """This module provides convenient python wrapping of otbApplications."""
 __version__ = "1.5.4"
+from .helpers import find_otb, logger, set_logger_level
+
+otb = find_otb()
 
 from .apps import *
-from .core import App, Output, Input, get_nbchannels, get_pixel_type
-from .functions import *  # pylint: disable=redefined-builtin
-from .helpers import logger, set_logger_level
+
+from .core import (
+    Input,
+    Output,
+    get_nbchannels,
+    get_pixel_type
+)
+
+from .functions import (  # pylint: disable=redefined-builtin
+    all,
+    any,
+    where,
+    clip,
+    run_tf_function,
+    define_processing_area
+)
diff --git a/pyotb/apps.py b/pyotb/apps.py
index e5287bbd21a30326b0c9a8341d1d9bb0fa74d3f4..a8dc464ef1f4381e84bdf588e32d2f660a3154c3 100644
--- a/pyotb/apps.py
+++ b/pyotb/apps.py
@@ -4,9 +4,9 @@ import os
 import sys
 from pathlib import Path
 
-from .helpers import logger, find_otb
-
-otb = find_otb()
+import otbApplication as otb
+from .helpers import logger
+from .core import OTBObject, DataInterface, RasterInterface, VectorInterface
 
 
 def get_available_applications(as_subprocess=False):
@@ -34,14 +34,14 @@ def get_available_applications(as_subprocess=False):
         cmd_args = [sys.executable, "-c", pycmd]
 
         try:
-            import subprocess  # pylint: disable=import-outside-toplevel
+            import subprocess
             params = {"env": env, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
             with subprocess.Popen(cmd_args, **params) as p:
                 logger.debug('Exec "%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
-                from ast import literal_eval  # pylint: disable=import-outside-toplevel
+                from ast import literal_eval
                 app_list = literal_eval(stdout.strip())
                 assert isinstance(app_list, (tuple, list))
         except subprocess.SubprocessError:
@@ -62,18 +62,72 @@ def get_available_applications(as_subprocess=False):
     return app_list
 
 
-AVAILABLE_APPLICATIONS = get_available_applications(as_subprocess=True)
+class App(OTBObject):
+    """Default parent class of an OTB app. This class should not be used directly without any *Interface."""
 
-# First core.py call (within __init__ scope)
-from .core import App  # pylint: disable=wrong-import-position
+    def __init__(self, appname, *args, **kwargs):
+        """Enables to init an OTB application as a oneliner.
 
-# 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)
-"""
+        Args:
+            appname: name of the application to initialize
+            *args: used for passing application parameters. Can be :
+                           - dictionary containing key-arguments enumeration. Useful when a key is python-reserved
+                             (e.g. "in") or contains reserved characters such as a point (e.g."mode.extent.unit")
+                           - string, App or Output, useful when the user wants to specify the input "in"
+                           - list, useful when the user wants to specify the input list 'il'
+            **kwargs: used for passing application parameters.
+                      e.g. il=['input1.tif', App_object2, App_object3.out], out='output.tif'
+
+        """
+        super().__init__(appname, *args, **kwargs)
+        self.description = self.app.GetDocLongDescription()
+
+    def find_output_files(self):
+        """Find output files on disk using path found in parameters. Log warning for missing files.
+
+        Returns:
+            list of files found on disk
+
+        """
+        files = []
+        missing = []
+        outputs = (p for p in self.out_param_keys if p in self.parameters)
+        for param in outputs:
+            filename = self.parameters[param]
+            # Remove filename extension
+            if "?" in filename:
+                filename = filename.split("?")[0]
+            path = Path(filename)
+            if path.exists():
+                files.append(path.absolute())
+            else:
+                missing.append(path.absolute())
+            for filename in missing:
+                logger.error("%s: execution seems to have failed, %s does not exist", self.name, filename)
+
+        return tuple(files)
+
+
+class MetaOTBApp(type):
+    """The metaclass (class constructor) to use with any App class child."""
+    def additional_bases(appname):  # pylint: disable=bad-mcs-method-argument
+        """Decide which Interface object the app should inherit depending of its output types."""
+        app = otb.Registry_CreateApplicationWithoutLogger(appname)
+        bases = set()
+        keys = app.GetParametersKeys()
+        for k in keys:
+            if app.GetParameterType(k) == otb.ParameterType_OutputImage:
+                bases.add(RasterInterface)
+            elif app.GetParameterType(k) == otb.ParameterType_OutputVectorData:
+                bases.add(VectorInterface)
+            elif app.GetParameterType(k) == otb.ParameterType_OutputFilename:
+                bases.add(DataInterface)
+        # Ensure DataInterface is last in bases in order to avoid erasing RasterInterface's __getitem__ function
+        return tuple(sorted(bases, key=lambda v: v.__name__, reverse=True))
+
+    def __new__(cls, name, bases, classdict):
+        """The function that will instantiate a new class object."""
+        return super().__new__(cls, name, bases + cls.additional_bases(name), classdict)
 
 
 class OTBTFApp(App):
@@ -89,13 +143,13 @@ class OTBTFApp(App):
 
         """
         if n_sources:
-            os.environ['OTB_TF_NSOURCES'] = str(int(n_sources))
+            os.environ["OTB_TF_NSOURCES"] = str(int(n_sources))
         else:
             # Retrieving the number of `source#.il` parameters
             params_dic = {k: v for arg in args if isinstance(arg, dict) for k, v in arg.items()}
-            n_sources = len([k for k in params_dic if 'source' in k and k.endswith('.il')])
+            n_sources = len([k for k in params_dic if "source" in k and k.endswith(".il")])
             if n_sources >= 1:
-                os.environ['OTB_TF_NSOURCES'] = str(n_sources)
+                os.environ["OTB_TF_NSOURCES"] = str(n_sources)
 
     def __init__(self, app_name, *args, n_sources=None, **kwargs):
         """Constructor for an OTBTFApp object.
@@ -110,31 +164,23 @@ class OTBTFApp(App):
         self.set_nb_sources(*args, n_sources=n_sources)
         super().__init__(app_name, *args, **kwargs)
 
+# TODO: more specialized classes
+
+
+# This is to enable aliases of Apps, i.e. using apps like `pyotb.AppName(...)` instead of `pyotb.App("AppName", ...)`
+_CODE_TEMPLATE = """
+class {name}(App, metaclass=MetaOTBApp):
+    def __init__(self, *args, **kwargs):
+        super().__init__('{name}', *args, **kwargs)
+"""
+
 
+AVAILABLE_APPLICATIONS = get_available_applications(as_subprocess=True)
 for _app in AVAILABLE_APPLICATIONS:
     # Customize the behavior for some OTBTF applications. The user doesn't need to set the env variable
     # `OTB_TF_NSOURCES`, it is handled in pyotb
-    if _app == 'TensorflowModelServe':
-        class TensorflowModelServe(OTBTFApp):
-            """Serve a Tensorflow model using OTBTF."""
-            def __init__(self, *args, n_sources=None, **kwargs):
-                """Constructor for a TensorflowModelServe object."""
-                super().__init__('TensorflowModelServe', *args, n_sources=n_sources, **kwargs)
-
-    elif _app == 'PatchesExtraction':
-        class PatchesExtraction(OTBTFApp):
-            """Extract patches using OTBTF."""
-            def __init__(self, *args, n_sources=None, **kwargs):
-                """Constructor for a PatchesExtraction object."""
-                super().__init__('PatchesExtraction', *args, n_sources=n_sources, **kwargs)
-
-    elif _app == 'TensorflowModelTrain':
-        class TensorflowModelTrain(OTBTFApp):
-            """Train a Tensorflow model using OTBTF."""
-            def __init__(self, *args, n_sources=None, **kwargs):
-                """Constructor for a TensorflowModelTrain object."""
-                super().__init__('TensorflowModelTrain', *args, n_sources=n_sources, **kwargs)
-
+    if _app in ("PatchesExtraction", "TensorflowModelTrain", "TensorflowModelServe"):
+        exec(_CODE_TEMPLATE.format(name=_app).replace("(App,", "(OTBTFApp,"))  # pylint: disable=exec-used
     # Default behavior for any OTB application
     else:
         exec(_CODE_TEMPLATE.format(name=_app))  # pylint: disable=exec-used
diff --git a/pyotb/core.py b/pyotb/core.py
index 5b9a895b35e342bc16af2b5ab7334b269fa33e49..04e378c262c46d8e8950cfd83775d31f0b5236eb 100644
--- a/pyotb/core.py
+++ b/pyotb/core.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 """This module is the core of pyotb."""
+from ast import literal_eval
 from pathlib import Path
 
 import numpy as np
@@ -8,11 +9,74 @@ import otbApplication as otb
 from .helpers import logger
 
 
-class otbObject:
-    """Base class that gathers common operations for any OTB in-memory raster."""
+class OTBObject:
+    """Base class that gathers common operations for any OTB application."""
     _name = ""
-    app = None
-    output_param = ""
+
+    def __init__(self, appname, *args, frozen=False, quiet=False, **kwargs):
+        """Common constructor for OTB applications. Handles in-memory connection between apps.
+
+        Args:
+            appname: name of the app, e.g. 'BandMath'
+            *args: used for passing application parameters. Can be :
+                           - dictionary containing key-arguments enumeration. Useful when a key is python-reserved
+                             (e.g. "in") or contains reserved characters such as a point (e.g."mode.extent.unit")
+                           - string, App or Output, useful when the user wants to specify the input "in"
+                           - list, useful when the user wants to specify the input list 'il'
+            frozen: freeze OTB app in order to use execute() later and avoid blocking process during __init___
+            quiet: whether to print logs of the OTB app
+            **kwargs: used for passing application parameters.
+                      e.g. il=['input1.tif', App_object2, App_object3.out], out='output.tif'
+
+        """
+        self.parameters = {}
+        self.appname = appname
+        self.quiet = quiet
+        if quiet:
+            self.app = otb.Registry.CreateApplicationWithoutLogger(appname)
+        else:
+            self.app = otb.Registry.CreateApplication(appname)
+        self.parameters_keys = tuple(self.app.GetParametersKeys())
+        self.out_param_types = dict(self.__get_out_param_types())
+        self.out_param_keys = tuple(self.out_param_types.keys())
+        self.exports_dict = {}
+        if args or kwargs:
+            self.set_parameters(*args, **kwargs)
+        self.frozen = frozen
+        if not frozen:
+            self.execute()
+
+    @property
+    def key_input_image(self):
+        """Get the name of first input image parameter."""
+        types = (otb.ParameterType_InputImage, otb.ParameterType_InputImageList)
+        for key in self.parameters_keys:
+            if self.app.GetParameterType(key) in types:
+                return key
+        return None
+
+    @property
+    def key_input_vector(self):
+        """Get the name of first input image parameter."""
+        types = (otb.ParameterType_InputVectorData, otb.ParameterType_InputVectorDataList)
+        for key in self.parameters_keys:
+            if self.app.GetParameterType(key) in types:
+                return key
+        return None
+
+    @property
+    def key_input_file(self):
+        """Get the name of first input image parameter."""
+        types = (otb.ParameterType_InputFilename, otb.ParameterType_InputFilenameList)
+        for key in self.parameters_keys:
+            if self.app.GetParameterType(key) in types:
+                return key
+        return None
+
+    @property
+    def key_input(self):
+        """Get the name of first input parameter, raster > vector > file."""
+        return self.key_input_image or self.key_input_vector or self.key_input_file
 
     @property
     def name(self):
@@ -22,18 +86,300 @@ class otbObject:
             user's defined name or appname
 
         """
-        return self._name or self.app.GetName()
+        return self._name or self.appname
 
     @name.setter
-    def name(self, val):
+    def name(self, name):
         """Set custom name.
 
         Args:
-          val: new name
+          name: new name
+
+        """
+        if isinstance(name, str):
+            self._name = name
+        else:
+            raise TypeError(f"{self.name}: bad type ({type(name)}) for application name, only str is allowed")
+
+    @property
+    def outputs(self):
+        """List of application outputs."""
+        return [getattr(self, key) for key in self.out_param_keys if key in self.parameters]
+
+    def set_parameters(self, *args, **kwargs):
+        """Set some parameters of the app.
+
+        When useful, e.g. for images list, this function appends the parameters
+        instead of overwriting them. Handles any parameters, i.e. in-memory & filepaths
+
+        Args:
+            *args: Can be : - dictionary containing key-arguments enumeration. Useful when a key is python-reserved
+                              (e.g. "in") or contains reserved characters such as a point (e.g."mode.extent.unit")
+                            - string, App or Output, useful when the user implicitly wants to set the param "in"
+                            - list, useful when the user implicitly wants to set the param "il"
+            **kwargs: keyword arguments e.g. il=['input1.tif', oApp_object2, App_object3.out], out='output.tif'
+
+        Raises:
+            Exception: when the setting of a parameter failed
+
+        """
+        parameters = kwargs
+        parameters.update(self.__parse_args(args))
+        # Going through all arguments
+        for key, obj in parameters.items():
+            if key not in self.parameters_keys:
+                raise KeyError(f'{self.name}: unknown parameter name "{key}"')
+            # When the parameter expects a list, if needed, change the value to list
+            if self.__is_key_list(key) and not isinstance(obj, (list, tuple)):
+                obj = [obj]
+                logger.info('%s: argument for parameter "%s" was converted to list', self.name, key)
+            try:
+                # This is when we actually call self.app.SetParameter*
+                self.__set_param(key, obj)
+            except (RuntimeError, TypeError, ValueError, KeyError) as e:
+                raise Exception(
+                    f"{self.name}: something went wrong before execution "
+                    f"(while setting parameter '{key}' to '{obj}')"
+                ) from e
+        # Update _parameters using values from OtbApplication object
+        otb_params = self.app.GetParameters().items()
+        otb_params = {k: str(v) if isinstance(v, otb.ApplicationProxy) else v for k, v in otb_params}
+        # Save parameters keys, and values as object attributes
+        self.parameters.update({**parameters, **otb_params})
+
+    def execute(self):
+        """Execute and write to disk if any output parameter has been set during init."""
+        logger.debug("%s: run execute() with parameters=%s", self.name, self.parameters)
+        try:
+            self.app.Execute()
+        except (RuntimeError, FileNotFoundError) as e:
+            raise Exception(f"{self.name}: error during during app execution") from e
+        self.frozen = False
+        logger.debug("%s: execution ended", self.name)
+        if any(key in self.parameters for key in self.out_param_keys):
+            self.flush()
+        self.save_objects()
+
+    def write(self, *args, **kwargs):
+        """Write data files to disk.
+
+        Args:
+            args: dict or a filename directly (for raster apps, default output image param will be used)
+            kwargs: key value pairs like parameter=filename
+
+        """
+        if isinstance(self, RasterInterface):
+            self.write_images(*args, **kwargs)
+            return
+        # Any other app, kwargs dict must contain app parameters keys only
+        for arg in args:
+            if isinstance(arg, dict):
+                kwargs.update(arg)
+        self.set_parameters(kwargs)
+        self.flush()
+        self.save_objects()
+
+    def flush(self):
+        """Flush data to disk, this is when WriteOutput is actually called.
+
+        Args:
+            parameters: key value pairs like {parameter: filename}
+            dtypes: optional dict to pass output data types (for rasters)
+
+        """
+        try:
+            logger.debug("%s: flushing data to disk", self.name)
+            self.app.WriteOutput()
+        except RuntimeError:
+            logger.debug("%s: failed with WriteOutput, executing once again with ExecuteAndWriteOutput", self.name)
+            self.app.ExecuteAndWriteOutput()
+
+    def save_objects(self):
+        """Saving app parameters and outputs as attributes, so that they can be accessed with `obj.key`.
+
+        This is useful when the key contains reserved characters such as a point eg "io.out"
+        """
+        for key in self.parameters_keys:
+            if key in dir(OTBObject):
+                continue  # skip forbidden attribute since it is already used by the class
+            value = self.parameters.get(key)  # basic parameters
+            if value is None:
+                try:
+                    value = self.app.GetParameterValue(key)  # any other app attribute (e.g. ReadImageInfo results)
+                except RuntimeError:
+                    continue  # this is when there is no value for key
+            # Convert output param path to Output object
+            if key in self.out_param_keys:
+                value = Output(self, key, value)
+            # Save attribute
+            setattr(self, key, value)
+
+    def summarize(self):
+        """Serialize an object and its pipeline into a dictionary.
+
+        Returns:
+            nested dictionary summarizing the pipeline
+
+        """
+        params = self.parameters
+        for k, p in params.items():
+            # In the following, we replace each parameter which is an otbObject, with its summary.
+            if isinstance(p, OTBObject):  # single parameter
+                params[k] = p.summarize()
+            elif isinstance(p, list):  # parameter list
+                params[k] = [pi.summarize() if isinstance(pi, OTBObject) else pi for pi in p]
+
+        return {"name": self.name, "parameters": params}
+
+    # Private functions
+    def __parse_args(self, args):
+        """Gather all input arguments in kwargs dict.
+
+        Args:
+            args: the list of arguments passed to set_parameters()
+
+        Returns:
+            a dictionary with the right keyword depending on the object
+
+        """
+        kwargs = {}
+        for arg in args:
+            if isinstance(arg, dict):
+                kwargs.update(arg)
+            elif isinstance(arg, (str, OTBObject)):
+                kwargs.update({self.key_input: arg})
+            elif isinstance(arg, list) and self.__is_key_list(self.key_input):
+                kwargs.update({self.key_input: arg})
+        return kwargs
+
+    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_ListView,
+            otb.ParameterType_InputVectorDataList,
+        )
+
+    def __is_key_images_list(self, key):
+        """Check if a key of the App is an input parameter image list."""
+        return self.app.GetParameterType(key) in (otb.ParameterType_InputImageList, otb.ParameterType_InputFilenameList)
+
+    def __get_out_param_types(self):
+        """Get output parameter data type (raster, vector, file).
+
+        Returns:
+            a dictionary like {param_name: param_type}
+
+        """
+        outfile_types = {
+            otb.ParameterType_OutputImage: "raster",
+            otb.ParameterType_OutputVectorData: "vector",
+            otb.ParameterType_OutputFilename: "file",
+        }
+        for k in self.parameters_keys:
+            t = self.app.GetParameterType(k)
+            if t in outfile_types:
+                yield k, outfile_types[t]
+
+    def __set_param(self, key, obj):
+        """Set one parameter, decide which otb.Application method to use depending on target object."""
+        if obj is None or (isinstance(obj, (list, tuple)) and not obj):
+            self.app.ClearValue(key)
+            return
+        if key not in self.parameters_keys:
+            raise Exception(
+                f"{self.name}: parameter '{key}' was not recognized. " f"Available keys are {self.parameters_keys}"
+            )
+        # Single-parameter cases
+        if isinstance(obj, RasterInterface):
+            self.app.ConnectImage(key, obj.app, obj.key_output_image)
+        elif isinstance(obj, otb.Application):  # this is for backward comp with plain OTB
+            self.app.ConnectImage(key, obj, get_out_images_param_keys(obj)[0])
+        elif key == "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(key, obj)
+        # Images list
+        elif self.__is_key_images_list(key):
+            # To enable possible in-memory connections, we go through the list and set the parameters one by one
+            for inp in obj:
+                if isinstance(inp, RasterInterface):
+                    self.app.ConnectImage(key, inp.app, inp.key_output_image)
+                elif isinstance(inp, otb.Application):  # this is for backward comp with plain OTB
+                    self.app.ConnectImage(key, obj, get_out_images_param_keys(inp)[0])
+                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(key, inp)
+        # List of any other types (str, int...)
+        else:
+            self.app.SetParameterValue(key, obj)
+
+    def __getattr__(self, name):
+        """This method is called when the default attribute access fails.
+
+        We choose to 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
+
+        Args:
+            name: attribute name
+
+        Returns:
+            attribute
+
+        Raises:
+            AttributeError: when `name` is not an attribute of self.app
 
         """
-        self._name = val
+        try:
+            res = getattr(self.app, name)
+            return res
+        except AttributeError as e:
+            raise AttributeError(f"{self.name}: could not find attribute `{name}`") from e
+
+    def __hash__(self):
+        """Override the default behaviour of the hash function.
+
+        Returns:
+            self hash
+
+        """
+        return id(self)
+
+    def __str__(self):
+        """Return a nice string representation with object id."""
+        return f"<pyotb.App {self.appname} object id {id(self)}>"
+
+
+class DataInterface:
+    """Object to define interface functions specific to data outputs (e/g. ReadImageInfo)."""
+    @property
+    def data(self):
+        """Expose app's output data values in a dictionary."""
+        keys = (k for k in self.parameters_keys if k not in self.out_param_keys + (self.key_input, 'ram'))
+        dct = {str(k): self[k] for k in keys}
+        empty_obj = ("", None, [], ())
+        return {k: v for k, v in dct.items() if not isinstance(v, otb.ApplicationProxy) and v not in empty_obj}
+
+    @property
+    def key_output_file(self):
+        """Get the name of first output image parameter."""
+        for key in self.parameters_keys:
+            if self.app.GetParameterType(key) == otb.ParameterType_OutputFilename:
+                return key
+        return None
+
+    def __getitem__(self, key):
+        """Access data values using string attributes."""
+        if isinstance(key, str):
+            return self.__dict__.get(key)
+        raise TypeError(f"{self.name}: bad key type ({type(key)}), while accessing object item")
+
 
+class RasterInterface:
+    """Object to define interface functions specific to raster outputs."""
     @property
     def dtype(self):
         """Expose the pixel type of an output image using numpy convention.
@@ -43,11 +389,19 @@ class otbObject:
 
         """
         try:
-            enum = self.app.GetParameterOutputImagePixelType(self.output_param)
+            enum = self.app.GetParameterOutputImagePixelType(self.key_output_image)
             return self.app.ConvertPixelTypeToNumpy(enum)
         except RuntimeError:
             return None
 
+    @property
+    def key_output_image(self):
+        """Get the name of first output image parameter."""
+        for key in self.parameters_keys:
+            if self.app.GetParameterType(key) == otb.ParameterType_OutputImage:
+                return key
+        return None
+
     @property
     def shape(self):
         """Enables to retrieve the shape of a pyotb object using numpy convention.
@@ -56,12 +410,80 @@ class otbObject:
             shape: (height, width, bands)
 
         """
-        width, height = self.app.GetImageSize(self.output_param)
-        bands = self.app.GetImageNbBands(self.output_param)
+        width, height = self.app.GetImageSize(self.key_output_image)
+        bands = self.app.GetImageNbBands(self.key_output_image)
         return (height, width, bands)
 
-    def write(self, *args, filename_extension="", pixel_type=None, **kwargs):
-        """Trigger execution, set output pixel type and write the output.
+    def export(self, key=None):
+        """Export a specific output image as numpy array and store it in object's exports_dict.
+
+        Args:
+            key: parameter key to export, if None then the default one will be used
+
+        Returns:
+            the exported numpy array
+
+        """
+        if key is None:
+            key = self.key_output_image
+        if key not in self.exports_dict:
+            self.exports_dict[key] = self.app.ExportImage(key)
+        return self.exports_dict[key]
+
+    def to_numpy(self, key=None, preserve_dtype=True, copy=False):
+        """Export a pyotb object to numpy array.
+
+        Args:
+            key: the output parameter name to export as numpy array
+            preserve_dtype: when set to True, the numpy array is created with the same pixel type as
+                            the otbObject first output. Default is True.
+            copy: whether to copy the output array, default is False
+                  required to True if preserve_dtype is False and the source app reference is lost
+
+        Returns:
+            a numpy array
+
+        """
+        data = self.export(key)
+        array = data["array"]
+        if preserve_dtype:
+            return array.astype(self.dtype)
+        if copy:
+            return array.copy()
+        return array
+
+    def to_rasterio(self, key=None):
+        """Export image as a numpy array and its metadata compatible with rasterio.
+
+        Args:
+            key: output parameter key to export
+
+        Returns:
+            array : a numpy array in the (bands, height, width) order
+            profile: a metadata dict required to write image using rasterio
+
+        """
+        if key is None:
+            key = self.key_output_image
+        array = self.to_numpy(preserve_dtype=True, copy=False, key=key)
+        array = np.moveaxis(array, 2, 0)
+        proj = self.app.GetImageProjection(key)
+        spacing_x, spacing_y = self.app.GetImageSpacing(key)
+        origin_x, origin_y = self.app.GetImageOrigin(key)
+        # Shift image origin since OTB is giving coordinates of pixel center instead of corners
+        origin_x, origin_y = origin_x - spacing_x / 2, origin_y - spacing_y / 2
+        profile = {
+            "crs": proj,
+            "dtype": array.dtype,
+            "count": array.shape[0],
+            "height": array.shape[1],
+            "width": array.shape[2],
+            "transform": (spacing_x, 0.0, origin_x, 0.0, spacing_y, origin_y),
+        }
+        return array, profile
+
+    def write_images(self, *args, filename_extension="", pixel_type=None, preserve_dtype=False, **kwargs):
+        """Set output pixel type and write the output raster files.
 
         Args:
             *args: Can be : - dictionary containing key-arguments enumeration. Useful when a key contains
@@ -75,7 +497,9 @@ class otbObject:
                                    outputs, all outputs are written with this unique type.
                                    Valid pixel types are uint8, uint16, uint32, int16, int32, float, double,
                                    cint16, cint32, cfloat, cdouble. (Default value = None)
+            preserve_dtype: propagate main input pixel type to outputs, in case pixel_type is None
             **kwargs: keyword arguments e.g. out='output.tif'
+
         """
         # Gather all input arguments in kwargs dict
         for arg in args:
@@ -83,102 +507,135 @@ class otbObject:
                 kwargs.update(arg)
             elif isinstance(arg, str) and kwargs:
                 logger.warning('%s: keyword arguments specified, ignoring argument "%s"', self.name, arg)
-            elif isinstance(arg, str):
-                kwargs.update({self.output_param: arg})
-
-        dtypes = {}
-        if isinstance(pixel_type, dict):
-            dtypes = {k: parse_pixel_type(v) for k, v in pixel_type.items()}
-        elif pixel_type is not None:
-            typ = parse_pixel_type(pixel_type)
-            if isinstance(self, App):
-                dtypes = {key: typ for key in self.output_parameters_keys}
-            elif isinstance(self, otbObject):
-                dtypes = {self.output_param: typ}
-
+            elif isinstance(arg, str) and self.key_output_image:
+                kwargs.update({self.key_output_image: arg})
+        # Append filename extension to filenames
         if filename_extension:
-            logger.debug('%s: using extended filename for outputs: %s', self.name, filename_extension)
-            if not filename_extension.startswith('?'):
+            logger.debug("%s: using extended filename for outputs: %s", self.name, filename_extension)
+            if not filename_extension.startswith("?"):
                 filename_extension = "?" + filename_extension
+            for key, value in kwargs.items():
+                if self.out_param_types[key] == 'raster' and '?' not in value:
+                    kwargs[key] = value + filename_extension
+        # Manage output pixel types
+        dtypes = {}
+        if pixel_type:
+            if isinstance(pixel_type, str):
+                type_name = self.app.ConvertPixelTypeToNumpy(self.__parse_pixel_type(pixel_type))
+                logger.debug('%s: output(s) will be written with type "%s"', self.name, type_name)
+                for key in kwargs:
+                    if self.out_param_types.get(key) == "raster":
+                        dtypes[key] = self.__parse_pixel_type(pixel_type)
+            elif isinstance(pixel_type, dict):
+                dtypes = {k: self.__parse_pixel_type(v) for k, v in pixel_type.items()}
+        elif preserve_dtype:
+            self.propagate_dtype()  # all outputs will have the same type as the main input raster
+        # Apply parameters
+        for key, output_filename in kwargs.items():
+            if key in dtypes:
+                self.propagate_dtype(key, dtypes[key])
+            self.set_parameters({key: output_filename})
 
-        # Case output parameter was set during App init
-        if not kwargs:
-            if self.output_param in self.parameters:
-                if dtypes:
-                    self.app.SetParameterOutputImagePixelType(self.output_param, dtypes[self.output_param])
-                if filename_extension:
-                    new_val = self.parameters[self.output_param] + filename_extension
-                    self.app.SetParameterString(self.output_param, new_val)
-            else:
-                raise ValueError(f'{self.app.GetName()}: Output parameter is missing.')
+        self.flush()
+        self.save_objects()
 
-        # Parse kwargs
-        for key, output_filename in kwargs.items():
-            # Stop process if a bad parameter is given
-            if key not in self.app.GetParametersKeys():
-                raise KeyError(f'{self.app.GetName()}: Unknown parameter key "{key}"')
-            # Check if extended filename was not provided twice
-            if '?' in output_filename and filename_extension:
-                logger.warning('%s: extended filename was provided twice. Using the one found in path.', self.name)
-            elif filename_extension:
-                output_filename += filename_extension
-
-            logger.debug('%s: "%s" parameter is %s', self.name, key, output_filename)
-            self.app.SetParameterString(key, output_filename)
+    def propagate_dtype(self, target_key=None, dtype=None):
+        """Propagate a pixel type from main input to every outputs, or to a target output key only.
 
-            if key in dtypes:
-                self.app.SetParameterOutputImagePixelType(key, dtypes[key])
+        With multiple inputs (if dtype is not provided), the type of the first input is considered.
+        With multiple outputs (if target_key is not provided), all outputs will be converted to the same pixel type.
 
-        logger.debug('%s: flushing data to disk', self.name)
-        try:
-            self.app.WriteOutput()
-        except RuntimeError:
-            logger.debug('%s: failed to simply write output, executing once again then writing', self.name)
-            self.app.ExecuteAndWriteOutput()
+        Args:
+            target_key: output param key to change pixel type
+            dtype: data type to use
 
-    def to_numpy(self, preserve_dtype=True, copy=False):
-        """Export a pyotb object to numpy array.
+        """
+        if not dtype:
+            param = self.parameters.get(self.key_input_image)
+            if not param:
+                logger.warning("%s: could not propagate pixel type from inputs to output", self.name)
+                return
+            if isinstance(param, (list, tuple)):
+                param = param[0]  # first image in "il"
+            try:
+                dtype = get_pixel_type(param)
+            except (TypeError, RuntimeError):
+                logger.warning('%s: unable to identify pixel type of key "%s"', self.name, param)
+                return
+
+        if target_key:
+            keys = [target_key]
+        else:
+            keys = [k for k in self.out_param_keys if self.out_param_types[k] == "raster"]
+        for key in keys:
+            # Set output pixel type
+            self.app.SetParameterOutputImagePixelType(key, dtype)
+
+    def read_values_at_coords(self, row, col, bands=None):
+        """Get pixel value(s) at a given YX coordinates.
 
         Args:
-            preserve_dtype: when set to True, the numpy array is created with the same pixel type as
-                                  the otbObject first output. Default is True.
-            copy: whether to copy the output array, default is False
-                  required to True if preserve_dtype is False and the source app reference is lost
+            row: index along Y / latitude axis
+            col: index along X / longitude axis
+            bands: band number, list or slice to fetch values from
 
         Returns:
-          a numpy array
+            single numerical value or a list of values for each band
 
         """
-        array = self.app.ExportImage(self.output_param)['array']
-        if preserve_dtype:
-            return array.astype(self.dtype)
-        if copy:
-            return array.copy()
-        return array
+        channels = []
+        app = OTBObject("PixelValue", self, coordy=row, coordx=col, frozen=False, quiet=True)
+        if bands is not None:
+            if isinstance(bands, int):
+                if bands < 0:
+                    bands = self.shape[2] + bands
+                channels = [bands]
+            elif isinstance(bands, slice):
+                channels = self.__channels_list_from_slice(bands)
+            elif not isinstance(bands, list):
+                raise TypeError(f"{self.name}: type '{bands}' cannot be interpreted as a valid slicing")
+            if channels:
+                app.app.Execute()
+                app.set_parameters({"cl": [f"Channel{n+1}" for n in channels]})
+        app.execute()
+        data = literal_eval(app.app.GetParameterString("value"))
+        if len(channels) == 1:
+            return data[0]
+        return data
+
+    def __channels_list_from_slice(self, bands):
+        """Get list of channels to read values at, from a slice."""
+        channels = None
+        start, stop, step = bands.start, bands.stop, bands.step
+        if step is None:
+            step = 1
+        if start is not None and stop is not None:
+            channels = list(range(start, stop, step))
+        elif start is not None and stop is None:
+            channels = list(range(start, self.shape[2], step))
+        elif start is None and stop is not None:
+            channels = list(range(0, stop, step))
+        elif start is None and stop is None:
+            channels = list(range(0, self.shape[2], step))
+        return channels
 
-    def to_rasterio(self):
-        """Export image as a numpy array and its metadata compatible with rasterio.
+    @staticmethod
+    def __parse_pixel_type(pixel_type):
+        """Convert one str pixel type to OTB integer enum if necessary.
+
+        Args:
+            pixel_type: pixel type. can be str, int or dict
 
         Returns:
-          array : a numpy array in the (bands, height, width) order
-          profile: a metadata dict required to write image using rasterio
+            pixel_type integer value
 
         """
-        array = self.to_numpy(preserve_dtype=True, copy=False)
-        array = np.moveaxis(array, 2, 0)
-        proj = self.app.GetImageProjection(self.output_param)
-        spacing_x, spacing_y = self.app.GetImageSpacing(self.output_param)
-        origin_x, origin_y = self.app.GetImageOrigin(self.output_param)
-        # Shift image origin since OTB is giving coordinates of pixel center instead of corners
-        origin_x, origin_y = origin_x - spacing_x / 2, origin_y - spacing_y / 2
-        profile = {
-            'crs': proj, 'dtype': array.dtype,
-            'count': array.shape[0], 'height': array.shape[1], 'width': array.shape[2],
-            'transform': (spacing_x, 0.0, origin_x, 0.0, spacing_y, origin_y)  # here we force pixel rotation to 0 !
-        }
-        return array, profile
+        if isinstance(pixel_type, str):  # this correspond to 'uint8' etc...
+            return getattr(otb, f"ImagePixelType_{pixel_type}")
+        if isinstance(pixel_type, int):
+            return pixel_type
+        raise ValueError(f"Bad pixel type specification ({pixel_type})")
 
-    # Special methods
     def __getitem__(self, key):
         """Override the default __getitem__ behaviour.
 
@@ -187,47 +644,35 @@ class otbObject:
         - 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]
+        - access pixel value(s) at a specified row, col index
 
         Args:
             key: attribute key
 
         Returns:
-            attribute or Slicer
+            attribute, pixel values or Slicer
+
         """
         # Accessing string attributes
         if isinstance(key, str):
             return self.__dict__.get(key)
+        # Accessing pixel value(s) using Y/X coordinates
+        if isinstance(key, tuple) and len(key) >= 2:
+            row, col = key[0], key[1]
+            if isinstance(row, int) and isinstance(col, int):
+                if row < 0 or col < 0:
+                    raise ValueError(f"{self.name}: can't read pixel value at negative coordinates ({row}, {col})")
+                channels = None
+                if len(key) == 3:
+                    channels = key[2]
+                return self.read_values_at_coords(row, col, channels)
         # Slicing
         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),)
-        (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 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
-
-        Args:
-            name: attribute name
-
-        Returns:
-            attribute
-
-        Raises:
-            AttributeError: when `name` is not an attribute of self.app
-
-        """
-        try:
-            res = getattr(self.app, name)
-            return res
-        except AttributeError as e:
-            raise AttributeError(f'{self.name}: could not find attribute `{name}`') from e
+        return Slicer(self, *key)
 
     def __add__(self, other):
         """Overrides the default addition and flavours it with BandMathX.
@@ -236,12 +681,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self + other
+            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)
+        return Operation("+", self, other)
 
     def __sub__(self, other):
         """Overrides the default subtraction and flavours it with BandMathX.
@@ -250,12 +695,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self - other
+            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)
+        return Operation("-", self, other)
 
     def __mul__(self, other):
         """Overrides the default subtraction and flavours it with BandMathX.
@@ -264,12 +709,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self * other
+            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)
+        return Operation("*", self, other)
 
     def __truediv__(self, other):
         """Overrides the default subtraction and flavours it with BandMathX.
@@ -278,12 +723,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self / other
+            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)
+        return Operation("/", self, other)
 
     def __radd__(self, other):
         """Overrides the default reverse addition and flavours it with BandMathX.
@@ -292,12 +737,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             other + self
+            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)
+        return Operation("+", other, self)
 
     def __rsub__(self, other):
         """Overrides the default subtraction and flavours it with BandMathX.
@@ -306,12 +751,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             other - self
+            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)
+        return Operation("-", other, self)
 
     def __rmul__(self, other):
         """Overrides the default multiplication and flavours it with BandMathX.
@@ -320,12 +765,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             other * self
+            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)
+        return Operation("*", other, self)
 
     def __rtruediv__(self, other):
         """Overrides the default division and flavours it with BandMathX.
@@ -334,12 +779,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             other / self
+            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)
+        return Operation("/", other, self)
 
     def __abs__(self):
         """Overrides the default abs operator and flavours it with BandMathX.
@@ -348,7 +793,7 @@ class otbObject:
             abs(self)
 
         """
-        return Operation('abs', self)
+        return Operation("abs", self)
 
     def __ge__(self, other):
         """Overrides the default greater or equal and flavours it with BandMathX.
@@ -357,12 +802,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self >= other
+            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)
+        return LogicalOperation(">=", self, other)
 
     def __le__(self, other):
         """Overrides the default less or equal and flavours it with BandMathX.
@@ -371,12 +816,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self <= other
+            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)
+        return LogicalOperation("<=", self, other)
 
     def __gt__(self, other):
         """Overrides the default greater operator and flavours it with BandMathX.
@@ -385,12 +830,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self > other
+            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)
+        return LogicalOperation(">", self, other)
 
     def __lt__(self, other):
         """Overrides the default less operator and flavours it with BandMathX.
@@ -399,12 +844,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self < other
+            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)
+        return LogicalOperation("<", self, other)
 
     def __eq__(self, other):
         """Overrides the default eq operator and flavours it with BandMathX.
@@ -413,12 +858,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self == other
+            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)
+        return LogicalOperation("==", self, other)
 
     def __ne__(self, other):
         """Overrides the default different operator and flavours it with BandMathX.
@@ -427,12 +872,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self != other
+            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)
+        return LogicalOperation("!=", self, other)
 
     def __or__(self, other):
         """Overrides the default or operator and flavours it with BandMathX.
@@ -441,12 +886,12 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self || other
+            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)
+        return LogicalOperation("||", self, other)
 
     def __and__(self, other):
         """Overrides the default and operator and flavours it with BandMathX.
@@ -455,25 +900,16 @@ class otbObject:
             other: the other member of the operation
 
         Returns:
-             self && other
+            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)
+        return LogicalOperation("&&", self, other)
 
     # TODO: other operations ?
     #  e.g. __pow__... cf https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types
 
-    def __hash__(self):
-        """Override the default behaviour of the hash function.
-
-        Returns:
-            self hash
-
-        """
-        return id(self)
-
     def __array__(self):
         """This is called when running np.asarray(pyotb_object).
 
@@ -499,322 +935,76 @@ class otbObject:
             a pyotb object
 
         """
-        if method == '__call__':
+        if method == "__call__":
             # Converting potential pyotb inputs to arrays
             arrays = []
             image_dic = None
             for inp in inputs:
                 if isinstance(inp, (float, int, np.ndarray, np.generic)):
                     arrays.append(inp)
-                elif isinstance(inp, otbObject):
-                    image_dic = inp.app.ExportImage(self.output_param)
-                    array = image_dic['array']
+                elif isinstance(inp, OTBObject):
+                    if not inp.exports_dict:
+                        inp.export()
+                    image_dic = inp.exports_dict[inp.key_output_image]
+                    array = image_dic["array"]
                     arrays.append(array)
                 else:
-                    print(type(self))
+                    logger.debug(type(self))
                     return NotImplemented
 
             # Performing the numpy operation
             result_array = ufunc(*arrays, **kwargs)
             result_dic = image_dic
-            result_dic['array'] = result_array
+            result_dic["array"] = result_array
 
-            # Importing back to OTB
-            app = App('ExtractROI', frozen=True, image_dic=result_dic)  # pass the result_dic just to keep reference
+            # Importing back to OTB, pass the result_dic just to keep reference
+            app = InMemoryImage("ExtractROI", image_dic=result_dic, frozen=True, quiet=True)
             if result_array.shape[2] == 1:
-                app.ImportImage('in', result_dic)
+                app.ImportImage("in", result_dic)
             else:
-                app.ImportVectorImage('in', result_dic)
+                app.ImportVectorImage("in", result_dic)
             app.execute()
             return app
 
         return NotImplemented
 
-    def summarize(self):
-        """Return a nested dictionary summarizing the otbObject.
 
-        Returns:
-            Nested dictionary summarizing the otbObject
+class VectorInterface:
+    """Object to define interface functions specific to vector outputs."""
 
-        """
-        params = self.parameters
-        for k, p in params.items():
-            # In the following, we replace each parameter which is an otbObject, with its summary.
-            if isinstance(p, otbObject):  # single parameter
-                params[k] = p.summarize()
-            elif isinstance(p, list):  # parameter list
-                params[k] = [pi.summarize() if isinstance(pi, otbObject) else pi for pi in p]
+    @property
+    def key_output_vector(self):
+        """Get the name of first output image parameter."""
+        for key in self.parameters_keys:
+            if self.app.GetParameterType(key) == otb.ParameterType_OutputVectorData:
+                return key
+        return None
 
-        return {"name": self.name, "parameters": params}
 
+class InMemoryImage(OTBObject, RasterInterface):
+    """Class of special in-memory objects like Slicer or Operation."""
 
-class App(otbObject):
-    """Class of an OTB app."""
-    def __init__(self, appname, *args, frozen=False, quiet=False,
-                 preserve_dtype=False, image_dic=None, **kwargs):
-        """Enables to init an OTB application as a oneliner. Handles in-memory connection between apps.
+    def __init__(self, appname, *args, image_dic=None, **kwargs):
+        """Default constructor for images in memory.
 
         Args:
-            appname: name of the app, e.g. 'Smoothing'
-            *args: used for passing application parameters. Can be :
-                           - dictionary containing key-arguments enumeration. Useful when a key is python-reserved
-                             (e.g. "in") or contains reserved characters such as a point (e.g."mode.extent.unit")
-                           - string, App or Output, useful when the user wants to specify the input "in"
-                           - list, useful when the user wants to specify the input list 'il'
-            frozen: freeze OTB app in order to use execute() later and avoid blocking process during __init___
-            quiet: whether to print logs of the OTB app
-            preserve_dtype: propagate the pixel type from inputs to output. If several inputs, the type of an
-                                  arbitrary input is considered. If several outputs, all will have the same type.
-            image_dic: enables to keep a reference to image_dic. image_dic is a dictionary, such as
+            appname: name of the application to initialize (BandMath, ExtractROI...)
+            args: other pyotb app arguments
+            image_dic: enables to keep a reference to input's image_dic. image_dic is a dictionary, such as
                        the result of app.ExportImage(). Use it when the app takes a numpy array as input.
                        See this related issue for why it is necessary to keep reference of object:
                        https://gitlab.orfeo-toolbox.org/orfeotoolbox/otb/-/issues/1824
-            **kwargs: used for passing application parameters.
-                      e.g. il=['input1.tif', App_object2, App_object3.out], out='output.tif'
+            kwargs: other pyotb app keyword arguments
 
         """
-        self.appname = appname
-        self.frozen = frozen
-        self.quiet = quiet
-        self.preserve_dtype = preserve_dtype
         self.image_dic = image_dic
-        if self.quiet:
-            self.app = otb.Registry.CreateApplicationWithoutLogger(appname)
-        else:
-            self.app = otb.Registry.CreateApplication(appname)
-        self.description = self.app.GetDocLongDescription()
-        self.output_parameters_keys = self.__get_output_parameters_keys()
-        if self.output_parameters_keys:
-            self.output_param = self.output_parameters_keys[0]
+        super().__init__(appname, *args, **kwargs)
 
-        self.parameters = {}
-        if (args or kwargs):
-            self.set_parameters(*args, **kwargs)
-        if not self.frozen:
-            self.execute()
 
-    def set_parameters(self, *args, **kwargs):
-        """Set some parameters of the app.
-
-        When useful, e.g. for images list, this function appends the parameters
-        instead of overwriting them. Handles any parameters, i.e. in-memory & filepaths
-
-        Args:
-            *args: Can be : - dictionary containing key-arguments enumeration. Useful when a key is python-reserved
-                              (e.g. "in") or contains reserved characters such as a point (e.g."mode.extent.unit")
-                            - string, App or Output, useful when the user implicitly wants to set the param "in"
-                            - list, useful when the user implicitly wants to set the param "il"
-            **kwargs: keyword arguments e.g. il=['input1.tif', oApp_object2, App_object3.out], out='output.tif'
-
-        Raises:
-            Exception: when the setting of a parameter failed
-
-        """
-        parameters = kwargs
-        parameters.update(self.__parse_args(args))
-        # Going through all arguments
-        for param, obj in parameters.items():
-            if param not in self.app.GetParametersKeys():
-                raise Exception(f"{self.name}: parameter '{param}' was not recognized. "
-                                f"Available keys are {self.app.GetParametersKeys()}")
-            # When the parameter expects a list, if needed, change the value to list
-            if self.__is_key_list(param) and not isinstance(obj, (list, tuple)):
-                parameters[param] = [obj]
-                obj = [obj]
-                logger.warning('%s: argument for parameter "%s" was converted to list', self.name, param)
-            try:
-                # This is when we actually call self.app.SetParameter*
-                self.__set_param(param, obj)
-            except (RuntimeError, TypeError, ValueError, KeyError) as e:
-                raise Exception(f"{self.name}: something went wrong before execution "
-                                f"(while setting parameter '{param}' to '{obj}')") from e
-        # Update _parameters using values from OtbApplication object
-        otb_params = self.app.GetParameters().items()
-        otb_params = {k: str(v) if isinstance(v, otb.ApplicationProxy) else v for k, v in otb_params}
-        self.parameters.update({**parameters, **otb_params})
-        # Update output images pixel types
-        if self.preserve_dtype:
-            self.__propagate_pixel_type()
-
-    def execute(self):
-        """Execute and write to disk if any output parameter has been set during init."""
-        logger.debug("%s: run execute() with parameters=%s", self.name, self.parameters)
-        try:
-            self.app.Execute()
-        except (RuntimeError, FileNotFoundError) as e:
-            raise Exception(f'{self.name}: error during during app execution') from e
-        self.frozen = False
-        logger.debug("%s: execution ended", self.name)
-        if self.__has_output_param_key():
-            logger.debug('%s: flushing data to disk', self.name)
-            self.app.WriteOutput()
-        self.__save_objects()
-
-    def find_output(self):
-        """Find output files on disk using path found in parameters.
-
-        Returns:
-            list of files found on disk
-
-        """
-        files = []
-        missing = []
-        outputs = [p for p in self.output_parameters_keys if p in self.parameters]
-        for param in outputs:
-            filename = self.parameters[param]
-            # Remove filename extension
-            if '?' in filename:
-                filename = filename.split('?')[0]
-            path = Path(filename)
-            if path.exists():
-                files.append(str(path.absolute()))
-            else:
-                missing.append(str(path.absolute()))
-        if missing:
-            missing = tuple(missing)
-            for filename in missing:
-                logger.error("%s: execution seems to have failed, %s does not exist", self.name, filename)
-
-        return files
-
-    # Private functions
-    def __get_output_parameters_keys(self):
-        """Get raster output parameter keys.
-
-        Returns:
-            output parameters keys
-        """
-        return [param for param in self.app.GetParametersKeys()
-                if self.app.GetParameterType(param) == otb.ParameterType_OutputImage]
-
-    def __has_output_param_key(self):
-        """Check if App has any output parameter key."""
-        if not self.output_param:
-            return True  # apps like ReadImageInfo with no filetype output param still needs to WriteOutput
-        types = (otb.ParameterType_OutputFilename, otb.ParameterType_OutputImage, otb.ParameterType_OutputVectorData)
-        outfile_params = [param for param in self.app.GetParametersKeys() if self.app.GetParameterType(param) in types]
-        return any(key in self.parameters for key in outfile_params)
-
-    @staticmethod
-    def __parse_args(args):
-        """Gather all input arguments in kwargs dict.
-
-        Returns:
-            a dictionary with the right keyword depending on the object
-
-        """
-        kwargs = {}
-        for arg in args:
-            if isinstance(arg, dict):
-                kwargs.update(arg)
-            elif isinstance(arg, (str, otbObject)):
-                kwargs.update({'in': arg})
-            elif isinstance(arg, list):
-                kwargs.update({'il': arg})
-        return kwargs
-
-    def __set_param(self, param, obj):
-        """Set one parameter, decide which otb.Application method to use depending on target object."""
-        if obj is not None:
-            # Single-parameter cases
-            if isinstance(obj, otbObject):
-                self.app.ConnectImage(param, obj.app, obj.output_param)
-            elif isinstance(obj, otb.Application):  # this is for backward comp with plain OTB
-                outparamkey = [param for param in obj.GetParametersKeys()
-                               if obj.GetParameterType(param) == otb.ParameterType_OutputImage][0]
-                self.app.ConnectImage(param, obj, outparamkey)
-            elif param == 'ram':  # SetParameterValue in OTB<7.4 doesn't work for ram parameter cf gitlab OTB issue 2200
-                self.app.SetParameterInt('ram', int(obj))
-            elif not isinstance(obj, list):  # any other parameters (str, int...)
-                self.app.SetParameterValue(param, obj)
-            # Images list
-            elif self.__is_key_images_list(param):
-                # To enable possible in-memory connections, we go through the list and set the parameters one by one
-                for inp in obj:
-                    if isinstance(inp, otbObject):
-                        self.app.ConnectImage(param, inp.app, inp.output_param)
-                    elif isinstance(inp, otb.Application):  # this is for backward comp with plain OTB
-                        outparamkey = [param for param in inp.GetParametersKeys() if
-                                       inp.GetParameterType(param) == otb.ParameterType_OutputImage][0]
-                        self.app.ConnectImage(param, inp, outparamkey)
-                    else:  # here `input` should be an image filepath
-                        # Append `input` to the list, do not overwrite any previously set element of the image list
-                        self.app.AddParameterStringList(param, inp)
-            # List of any other types (str, int...)
-            else:
-                self.app.SetParameterValue(param, obj)
-
-    def __propagate_pixel_type(self):
-        """Propagate the pixel type from inputs to output.
-
-        For several inputs, or with an image list, the type of the first input is considered.
-        If several outputs, all outputs will have the same type.
-
-        """
-        pixel_type = None
-        for key, param in self.parameters.items():
-            if self.__is_key_input_image(key):
-                if not param:
-                    continue
-                if isinstance(param, list):
-                    param = param[0]  # first image in "il"
-                try:
-                    pixel_type = get_pixel_type(param)
-                    type_name = self.app.ConvertPixelTypeToNumpy(pixel_type)
-                    logger.debug('%s: output(s) will be written with type "%s"', self.name, type_name)
-                    for out_key in self.output_parameters_keys:
-                        self.app.SetParameterOutputImagePixelType(out_key, pixel_type)
-                    return
-                except TypeError:
-                    pass
-
-        logger.warning("%s: could not propagate pixel type from inputs to output, no valid input found", self.name)
-
-    def __save_objects(self):
-        """Saving app parameters and outputs as attributes, so that they can be accessed with `obj.key`.
-
-        This is useful when the key contains reserved characters such as a point eg "io.out"
-        """
-        for key in self.app.GetParametersKeys():
-            if key == 'parameters':  # skip forbidden attribute since it is already used by the App class
-                continue
-            value = None
-            if key in self.output_parameters_keys:  # raster outputs
-                value = Output(self, key)
-            elif key in self.parameters:  # user or default app parameters
-                value = self.parameters[key]
-            else:  # any other app attribute (e.g. ReadImageInfo results)
-                try:
-                    value = self.app.GetParameterValue(key)
-                except RuntimeError:
-                    pass  # this is when there is no value for key
-            if value is not None:
-                setattr(self, key, value)
-
-    def __is_key_input_image(self, key):
-        """Check if a key of the App is an input parameter image list."""
-        return self.app.GetParameterType(key) in (otb.ParameterType_InputImage, otb.ParameterType_InputImageList)
-
-    def __is_key_list(self, key):
-        """Check if a key of the App is an input parameter list."""
-        return self.app.GetParameterType(key) in (otb.ParameterType_InputImageList, otb.ParameterType_StringList,
-                                                  otb.ParameterType_InputFilenameList, otb.ParameterType_ListView,
-                                                  otb.ParameterType_InputVectorDataList)
-
-    def __is_key_images_list(self, key):
-        """Check if a key of the App is an input parameter image list."""
-        return self.app.GetParameterType(key) in (otb.ParameterType_InputImageList, otb.ParameterType_InputFilenameList)
-
-    # Special methods
-    def __str__(self):
-        """Return a nice string representation with object id."""
-        return f'<pyotb.App {self.appname} object id {id(self)}>'
-
-
-class Slicer(App):
+class Slicer(InMemoryImage):
     """Slicer objects i.e. when we call something like raster[:, :, 2] from Python."""
 
-    def __init__(self, x, rows, cols, channels):
+    def __init__(self, obj, rows, cols, channels):
         """Create a slicer object, that can be used directly for writing or inside a BandMath.
 
         It contains :
@@ -822,22 +1012,20 @@ class Slicer(App):
         - in case the user only wants to extract one band, an expression such as "im1b#"
 
         Args:
-            x: input
+            obj: input
             rows: slice along Y / Latitude axis
             cols: slice along X / Longitude axis
             channels: channels, can be slicing, list or int
 
         """
-        # Initialize the app that will be used for writing the slicer
-        self.name = 'Slicer'
-
-        self.output_parameter_key = 'out'
-        parameters = {'in': x, 'mode': 'extent'}
-        super().__init__('ExtractROI', parameters, preserve_dtype=True, frozen=True)
+        super().__init__("ExtractROI", {"in": obj, "mode": "extent"}, quiet=True, frozen=True)
+        self.name = "Slicer"
+        self.rows, self.cols = rows, cols
+        parameters = {}
         # Channel slicing
         if channels != slice(None, None, None):
             # Trigger source app execution if needed
-            nb_channels = get_nbchannels(x)
+            nb_channels = get_nbchannels(obj)
             self.app.Execute()  # this is needed by ExtractROI for setting the `cl` parameter
             # if needed, converting int to list
             if isinstance(channels, int):
@@ -853,90 +1041,39 @@ class Slicer(App):
             elif isinstance(channels, tuple):
                 channels = list(channels)
             elif not isinstance(channels, list):
-                raise ValueError(f'Invalid type for channels, should be int, slice or list of bands. : {channels}')
+                raise ValueError(f"Invalid type for channels, should be int, slice or list of bands. : {channels}")
 
             # Change the potential negative index values to reverse index
             channels = [c if c >= 0 else nb_channels + c for c in channels]
-            parameters.update({'cl': [f'Channel{i + 1}' for i in channels]})
+            parameters.update({"cl": [f"Channel{i + 1}" for i in channels]})
 
         # Spatial slicing
         spatial_slicing = False
-        # TODO: handle PixelValue app so that accessing value is possible, e.g. raster[120, 200, 0]
         # TODO TBD: handle the step value in the slice so that NN undersampling is possible ? e.g. raster[::2, ::2]
         if rows.start is not None:
-            parameters.update({'mode.extent.uly': rows.start})
+            parameters.update({"mode.extent.uly": rows.start})
             spatial_slicing = True
         if rows.stop is not None and rows.stop != -1:
-            parameters.update(
-                {'mode.extent.lry': rows.stop - 1})  # subtract 1 to be compliant with python convention
+            parameters.update({"mode.extent.lry": rows.stop - 1})  # subtract 1 to respect python convention
             spatial_slicing = True
         if cols.start is not None:
-            parameters.update({'mode.extent.ulx': cols.start})
+            parameters.update({"mode.extent.ulx": cols.start})
             spatial_slicing = True
         if cols.stop is not None and cols.stop != -1:
-            parameters.update(
-                {'mode.extent.lrx': cols.stop - 1})  # subtract 1 to be compliant with python convention
+            parameters.update({"mode.extent.lrx": cols.stop - 1})  # subtract 1 to respect python convention
             spatial_slicing = True
+
         # Execute app
-        self.set_parameters(**parameters)
+        self.set_parameters(parameters)
         self.execute()
-
         # These are some attributes when the user simply wants to extract *one* band to be used in an Operation
         if not spatial_slicing and isinstance(channels, list) and len(channels) == 1:
             self.one_band_sliced = channels[0] + 1  # OTB convention: channels start at 1
-            self.input = x
-
-
-class Input(App):
-    """Class for transforming a filepath to pyOTB object."""
-
-    def __init__(self, filepath):
-        """Constructor for an Input object.
-
-        Args:
-            filepath: raster file path
-
-        """
-        self.filepath = filepath
-        super().__init__('ExtractROI', {'in': self.filepath}, preserve_dtype=True)
+            self.input = obj
+        self.propagate_dtype()
 
-    def __str__(self):
-        """Return a nice string representation with input file path."""
-        return f'<pyotb.Input object from {self.filepath}>'
-
-
-class Output(otbObject):
-    """Class for output of an app."""
-
-    def __init__(self, app, output_parameter_key):
-        """Constructor for an Output object.
-
-        Args:
-            app: The pyotb App
-            output_parameter_key: Output parameter key
-
-        """
-        # Keeping the OTB app and the pyotb app
-        self.pyotb_app, self.app = app, app.app
-        self.parameters = self.pyotb_app.parameters
-        self.output_param = output_parameter_key
-        self.name = f'Output {output_parameter_key} from {self.app.GetName()}'
-
-    def summarize(self):
-        """Return the summary of the pipeline that generates the Output object.
-
-        Returns:
-            Nested dictionary summarizing the pipeline that generates the Output object.
-
-        """
-        return self.pyotb_app.summarize()
 
-    def __str__(self):
-        """Return a nice string representation with object id."""
-        return f'<pyotb.Output {self.app.GetName()} object, id {id(self)}>'
-
-
-class Operation(App):
+class Operation(InMemoryImage):
     """Class for arithmetic/math operations done in Python.
 
     Example:
@@ -976,9 +1113,7 @@ class Operation(App):
         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"
         # creating a dictionary that is like {str(input1): 'im1', 'image2.tif': 'im2', ...}.
         # NB: the keys of the dictionary are strings-only, instead of 'complex' objects, to enable easy serialization
@@ -988,20 +1123,18 @@ class Operation(App):
         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}'
+                    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 ...
+        # Getting unique image inputs, in the order im1, im2, im3 ...
         self.unique_inputs = [mapping_str_to_input[str_input] for str_input in sorted(self.im_dic, key=self.im_dic.get)]
-        self.output_param = 'out'
-
         # Computing the BandMath or BandMathX app
         self.exp_bands, self.exp = self.get_real_exp(self.fake_exp_bands)
+        # Init app
         self.name = f'Operation exp="{self.exp}"'
-
-        appname = 'BandMath' if len(self.exp_bands) == 1 else 'BandMathX'
-        super().__init__(appname, il=self.unique_inputs, exp=self.exp)
+        appname = "BandMath" if len(self.exp_bands) == 1 else "BandMathX"
+        super().__init__(appname, il=self.unique_inputs, exp=self.exp, quiet=True)
 
     def create_fake_exp(self, operator, inputs, nb_bands=None):
         """Create a 'fake' expression.
@@ -1019,18 +1152,18 @@ class Operation(App):
 
         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:
+        if operator == "?" and nb_bands:
             pass
         # For any other operations, the output number of bands is the same as inputs
         else:
-            if any(isinstance(inp, Slicer) and hasattr(inp, 'one_band_sliced') for inp in inputs):
+            if any(isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced") for inp in inputs):
                 nb_bands = 1
             else:
                 nb_bands_list = [get_nbchannels(inp) for inp in inputs if not isinstance(inp, (float, int))]
                 # check that all inputs have the same nb of bands
                 if len(nb_bands_list) > 1:
                     if not all(x == nb_bands_list[0] for x in nb_bands_list):
-                        raise Exception('All images do not have the same number of bands')
+                        raise Exception("All images do not have the same number of bands")
                 nb_bands = nb_bands_list[0]
 
         # Create a list of fake expressions, each item of the list corresponding to one band
@@ -1046,12 +1179,14 @@ class Operation(App):
                         cond_band = 1
                     else:
                         cond_band = band
-                    fake_exp, corresponding_inputs, nb_channels = self.create_one_input_fake_exp(inp, cond_band,
-                                                                                                 keep_logical=True)
+                    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(inp, band,
-                                                                                                 keep_logical=False)
+                    fake_exp, corresponding_inputs, nb_channels = self.create_one_input_fake_exp(
+                        inp, band, keep_logical=False
+                    )
                 fake_exps.append(fake_exp)
                 # Reference the inputs and nb of channels (only on first pass in the loop to avoid duplicates)
                 if i == 0 and corresponding_inputs and nb_channels:
@@ -1060,13 +1195,13 @@ class Operation(App):
 
             # Generating the fake expression of the whole operation
             if len(inputs) == 1:  # this is only for 'abs'
-                fake_exp = f'({operator}({fake_exps[0]}))'
+                fake_exp = f"({operator}({fake_exps[0]}))"
             elif len(inputs) == 2:
                 # We create here the "fake" expression. For example, for a BandMathX expression such as '2 * im1 + im2',
                 # the false expression stores the expression 2 * str(input1) + str(input2)
-                fake_exp = f'({fake_exps[0]} {operator} {fake_exps[1]})'
-            elif len(inputs) == 3 and operator == '?':  # this is only for ternary expression
-                fake_exp = f'({fake_exps[0]} ? {fake_exps[1]} : {fake_exps[2]})'
+                fake_exp = f"({fake_exps[0]} {operator} {fake_exps[1]})"
+            elif len(inputs) == 3 and operator == "?":  # this is only for ternary expression
+                fake_exp = f"({fake_exps[0]} ? {fake_exps[1]} : {fake_exps[2]})"
 
             self.fake_exp_bands.append(fake_exp)
 
@@ -1091,7 +1226,7 @@ class Operation(App):
             exp_bands.append(one_band_exp)
 
         # Form the final expression (e.g. 'im1b1 + 1; im1b2 + 1')
-        exp = ';'.join(exp_bands)
+        exp = ";".join(exp_bands)
 
         return exp_bands, exp
 
@@ -1116,8 +1251,8 @@ class Operation(App):
 
         """
         # Special case for one-band slicer
-        if isinstance(x, Slicer) and hasattr(x, 'one_band_sliced'):
-            if keep_logical and isinstance(x.input, logicalOperation):
+        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
@@ -1128,11 +1263,11 @@ class Operation(App):
                 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(x.input) + f'b{x.one_band_sliced}'
+                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(x, logicalOperation):
+        # For LogicalOperation, we save almost the same attributes as an Operation
+        elif keep_logical and isinstance(x, LogicalOperation):
             fake_exp = x.logical_fake_exp_bands[band - 1]
             inputs = x.inputs
             nb_channels = x.nb_channels
@@ -1150,23 +1285,22 @@ class Operation(App):
             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(x) + f'b{band}'
+            fake_exp = str(x) + f"b{band}"
 
         return fake_exp, inputs, nb_channels
 
     def __str__(self):
-        """Return a nice string representation with object id."""
-        return f'<pyotb.Operation `{self.operator}` object, id {id(self)}>'
+        """Return a nice string representation with operator and object id."""
+        return f"<pyotb.Operation `{self.operator}` object, id {id(self)}>"
 
 
-class logicalOperation(Operation):
+class LogicalOperation(Operation):
     """A specialization of Operation class for boolean logical operations i.e. >, <, >=, <=, ==, !=, `&` and `|`.
 
     The only difference is that not only the BandMath expression is saved (e.g. "im1b1 > 0 ? 1 : 0"), but also the
     logical expression (e.g. "im1b1 > 0")
 
     """
-
     def __init__(self, operator, *inputs, nb_bands=None):
         """Constructor for a logicalOperation object.
 
@@ -1192,39 +1326,94 @@ class logicalOperation(Operation):
 
         """
         # For any other operations, the output number of bands is the same as inputs
-        if any(isinstance(inp, Slicer) and hasattr(inp, 'one_band_sliced') for inp in inputs):
+        if any(isinstance(inp, Slicer) and hasattr(inp, "one_band_sliced") for inp in inputs):
             nb_bands = 1
         else:
             nb_bands_list = [get_nbchannels(inp) for inp in inputs if not isinstance(inp, (float, int))]
             # check that all inputs have the same nb of bands
             if len(nb_bands_list) > 1:
                 if not all(x == nb_bands_list[0] for x in nb_bands_list):
-                    raise Exception('All images do not have the same number of bands')
+                    raise Exception("All images do not have the same number of bands")
             nb_bands = nb_bands_list[0]
 
         # Create a list of fake exp, each item of the list corresponding to one band
         for i, band in enumerate(range(1, nb_bands + 1)):
             fake_exps = []
             for inp in inputs:
-                fake_exp, corresponding_inputs, nb_channels = super().create_one_input_fake_exp(inp, band,
-                                                                                                keep_logical=True)
+                fake_exp, corresp_inputs, nb_channels = super().create_one_input_fake_exp(inp, band, keep_logical=True)
                 fake_exps.append(fake_exp)
                 # Reference the inputs and nb of channels (only on first pass in the loop to avoid duplicates)
-                if i == 0 and corresponding_inputs and nb_channels:
-                    self.inputs.extend(corresponding_inputs)
+                if i == 0 and corresp_inputs and nb_channels:
+                    self.inputs.extend(corresp_inputs)
                     self.nb_channels.update(nb_channels)
 
             # We create here the "fake" expression. For example, for a BandMathX expression such as 'im1 > im2',
             # the logical fake expression stores the expression "str(input1) > str(input2)"
-            logical_fake_exp = f'({fake_exps[0]} {operator} {fake_exps[1]})'
+            logical_fake_exp = f"({fake_exps[0]} {operator} {fake_exps[1]})"
 
             # We keep the logical expression, useful if later combined with other logical operations
             self.logical_fake_exp_bands.append(logical_fake_exp)
             # We create a valid BandMath expression, e.g. "str(input1) > str(input2) ? 1 : 0"
-            fake_exp = f'({logical_fake_exp} ? 1 : 0)'
+            fake_exp = f"({logical_fake_exp} ? 1 : 0)"
             self.fake_exp_bands.append(fake_exp)
 
 
+class FileIO:
+    """Base class of an IO file object."""
+    # TODO: check file exists, create missing directories, ..?
+
+
+class Input(InMemoryImage, FileIO):
+    """Class for transforming a filepath to pyOTB object."""
+
+    def __init__(self, filepath):
+        """Default constructor.
+
+        Args:
+            filepath: the path of an input image
+
+        """
+        super().__init__("ExtractROI", {"in": str(filepath)})
+        self._name = f"Input from {filepath}"
+        self.filepath = Path(filepath)
+        self.propagate_dtype()
+
+    def __str__(self):
+        """Return a nice string representation with file path."""
+        return f"<pyotb.Input object from {self.filepath}>"
+
+
+class Output(FileIO):
+    """Object that behave like a pointer to a specific application output file."""
+
+    def __init__(self, source_app, param_key, filepath=None):
+        """Constructor for an Output object.
+
+        Args:
+            source_app: The pyotb App to store reference from
+            param_key: Output parameter key of the target app
+            filepath: path of the output file (if not in memory)
+
+        """
+        self.source_app = source_app
+        self.param_key = param_key
+        self.filepath = None
+        if filepath:
+            if '?' in filepath:
+                filepath = filepath.split('?')[0]
+            self.filepath = Path(filepath)
+        self.name = f"Output {param_key} from {self.source_app.name}"
+
+    def __str__(self):
+        """Return a nice string representation with source app name and object id."""
+        return f"<pyotb.Output {self.source_app.name} object, id {id(self)}>"
+
+
+def get_out_images_param_keys(app):
+    """Return every output parameter keys of an OTB app."""
+    return [key for key in app.GetParametersKeys() if app.GetParameterType(key) == otb.ParameterType_OutputImage]
+
+
 def get_nbchannels(inp):
     """Get the nb of bands of input image.
 
@@ -1235,15 +1424,14 @@ def get_nbchannels(inp):
         number of bands in image
 
     """
-    if isinstance(inp, otbObject):
+    if isinstance(inp, OTBObject):
         nb_channels = inp.shape[-1]
     else:
-        # Executing the app, without printing its log
         try:
-            info = App("ReadImageInfo", inp, quiet=True)
-            nb_channels = info.GetParameterInt("numberbands")
+            info = OTBObject("ReadImageInfo", inp, quiet=True)
+            nb_channels = info.numberbands
         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
+            raise TypeError(f"Could not get the number of channels of `{inp}`. Not a filepath or wrong filepath") from e
     return nb_channels
 
 
@@ -1259,39 +1447,29 @@ def get_pixel_type(inp):
 
     """
     if isinstance(inp, str):
-        # Executing the app, without printing its log
         try:
-            info = App("ReadImageInfo", inp, quiet=True)
+            info = OTBObject("ReadImageInfo", inp, quiet=True)
         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...
+            raise TypeError(f"Could not get the pixel type of `{inp}`. Not a filepath or wrong filepath") from info_err
+        datatype = info.datatype  # which is such as short, float...
         if not datatype:
-            raise Exception(f'Unable to read pixel type of image {inp}')
-        datatype_to_pixeltype = {'unsigned_char': 'uint8', 'short': 'int16', 'unsigned_short': 'uint16',
-                                 'int': 'int32', 'unsigned_int': 'uint32', 'long': 'int32', 'ulong': 'uint32',
-                                 'float': 'float', 'double': 'double'}
+            raise TypeError(f"Unable to read pixel type of image {inp}")
+        datatype_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 = datatype_to_pixeltype[datatype]
-        pixel_type = getattr(otb, f'ImagePixelType_{pixel_type}')
-    elif isinstance(inp, (otbObject)):
-        pixel_type = inp.GetParameterOutputImagePixelType(inp.output_param)
+        pixel_type = getattr(otb, f"ImagePixelType_{pixel_type}")
+    elif isinstance(inp, (OTBObject)):
+        pixel_type = inp.app.GetParameterOutputImagePixelType(inp.key_output_image)
     else:
-        raise TypeError(f'Could not get the pixel type. Not supported type: {inp}')
+        raise TypeError(f"Could not get the pixel type. Not supported type: {inp}")
 
     return pixel_type
-
-
-def parse_pixel_type(pixel_type):
-    """Convert one str pixel type to OTB integer enum if necessary.
-
-    Args:
-        pixel_type: pixel type. can be str, int or dict
-
-    Returns:
-        pixel_type integer value
-
-    """
-    if isinstance(pixel_type, str):  # this correspond to 'uint8' etc...
-        return getattr(otb, f'ImagePixelType_{pixel_type}')
-    if isinstance(pixel_type, int):
-        return pixel_type
-    raise ValueError(f'Bad pixel type specification ({pixel_type})')
diff --git a/pyotb/functions.py b/pyotb/functions.py
index eee401a3ba6638df6e86f9efe96cc8238c9a4655..4c709bdec16dd678d20bd055de0130c21ba0106e 100644
--- a/pyotb/functions.py
+++ b/pyotb/functions.py
@@ -7,7 +7,8 @@ import textwrap
 import uuid
 from collections import Counter
 
-from .core import (otbObject, App, Input, Operation, logicalOperation, get_nbchannels)
+from .apps import ExtractROI, Superimpose
+from .core import RasterInterface, Input, Operation, LogicalOperation, get_nbchannels
 from .helpers import logger
 
 
@@ -76,8 +77,7 @@ def clip(a, a_min, a_max):
     if isinstance(a, str):
         a = Input(a)
 
-    res = where(a <= a_min, a_min,
-                where(a >= a_max, a_max, a))
+    res = where(a <= a_min, a_min, where(a >= a_max, a_max, a))
     return res
 
 
@@ -111,25 +111,25 @@ def all(*inputs):  # pylint: disable=redefined-builtin
     # Checking that all bands of the single image are True
     if len(inputs) == 1:
         inp = inputs[0]
-        if isinstance(inp, logicalOperation):
+        if isinstance(inp, LogicalOperation):
             res = inp[:, :, 0]
         else:
-            res = (inp[:, :, 0] != 0)
+            res = inp[:, :, 0] != 0
 
         for band in range(1, inp.shape[-1]):
-            if isinstance(inp, logicalOperation):
+            if isinstance(inp, LogicalOperation):
                 res = res & inp[:, :, band]
             else:
                 res = res & (inp[:, :, band] != 0)
 
     # Checking that all images are True
     else:
-        if isinstance(inputs[0], logicalOperation):
+        if isinstance(inputs[0], LogicalOperation):
             res = inputs[0]
         else:
-            res = (inputs[0] != 0)
+            res = inputs[0] != 0
         for inp in inputs[1:]:
-            if isinstance(inp, logicalOperation):
+            if isinstance(inp, LogicalOperation):
                 res = res & inp
             else:
                 res = res & (inp != 0)
@@ -166,25 +166,25 @@ def any(*inputs):  # pylint: disable=redefined-builtin
     # Checking that at least one band of the image is True
     if len(inputs) == 1:
         inp = inputs[0]
-        if isinstance(inp, logicalOperation):
+        if isinstance(inp, LogicalOperation):
             res = inp[:, :, 0]
         else:
-            res = (inp[:, :, 0] != 0)
+            res = inp[:, :, 0] != 0
 
         for band in range(1, inp.shape[-1]):
-            if isinstance(inp, logicalOperation):
+            if isinstance(inp, LogicalOperation):
                 res = res | inp[:, :, band]
             else:
                 res = res | (inp[:, :, band] != 0)
 
     # Checking that at least one image is True
     else:
-        if isinstance(inputs[0], logicalOperation):
+        if isinstance(inputs[0], LogicalOperation):
             res = inputs[0]
         else:
-            res = (inputs[0] != 0)
+            res = inputs[0] != 0
         for inp in inputs[1:]:
-            if isinstance(inp, logicalOperation):
+            if isinstance(inp, LogicalOperation):
                 res = res | inp
             else:
                 res = res | (inp != 0)
@@ -296,11 +296,12 @@ def run_tf_function(func):
 
         # 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()}')
+        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")
@@ -351,9 +352,9 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
     metadatas = {}
     for inp in inputs:
         if isinstance(inp, str):  # this is for filepaths
-            metadata = Input(inp).GetImageMetaData('out')
-        elif isinstance(inp, otbObject):
-            metadata = inp.GetImageMetaData(inp.output_param)
+            metadata = Input(inp).GetImageMetaData("out")
+        elif isinstance(inp, RasterInterface):
+            metadata = inp.GetImageMetaData(inp.key_output_raster)
         else:
             raise TypeError(f"Wrong input : {inp}")
         metadatas[inp] = metadata
@@ -408,11 +409,11 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
         for inp in inputs:
             try:
                 params = {
-                    'in': inp, 'mode': 'extent', 'mode.extent.unit': 'phy',
+                    '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', params)
+                new_input = ExtractROI(inp, params)
                 # 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
@@ -453,7 +454,7 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
         new_inputs = []
         for inp in inputs:
             if metadatas[inp]['GeoTransform'][1] != pixel_size:
-                superimposed = App('Superimpose', inr=reference_input, inm=inp, interpolator=interpolator)
+                superimposed = Superimpose(inr=reference_input, inm=inp, interpolator=interpolator)
                 new_inputs.append(superimposed)
             else:
                 new_inputs.append(inp)
@@ -478,7 +479,7 @@ def define_processing_area(*args, window_rule='intersection', pixel_size_rule='m
     new_inputs = []
     for inp in inputs:
         if image_sizes[inp] != most_common_image_size:
-            superimposed = App('Superimpose', inr=same_size_images[0], inm=inp, interpolator=interpolator)
+            superimposed = Superimpose(inr=same_size_images[0], inm=inp, interpolator=interpolator)
             new_inputs.append(superimposed)
         else:
             new_inputs.append(inp)
diff --git a/pyotb/helpers.py b/pyotb/helpers.py
index 742fee18be4bfbf6951d8e754cb102d470b16b4a..ff30e71a93d7926073195c3be7ad32c503d598b6 100644
--- a/pyotb/helpers.py
+++ b/pyotb/helpers.py
@@ -111,7 +111,7 @@ def set_environment(prefix):
     if not prefix.exists():
         raise FileNotFoundError(str(prefix))
     built_from_source = False
-    if not (prefix / 'README').exists():
+    if not (prefix / "README").exists():
         built_from_source = True
     # External libraries
     lib_dir = __find_lib(prefix)
@@ -168,7 +168,7 @@ def __find_lib(prefix=None, otb_module=None):
 
     """
     if prefix is not None:
-        lib_dir = prefix / 'lib'
+        lib_dir = prefix / "lib"
         if lib_dir.exists():
             return lib_dir.absolute()
     if otb_module is not None:
@@ -278,16 +278,22 @@ def __suggest_fix_import(error_message, prefix):
     if sys.platform == "linux":
         if error_message.startswith('libpython3.'):
             logger.critical("It seems like you need to symlink or recompile python bindings")
-            if sys.executable.startswith('/usr/bin'):
+            if sys.executable.startswith("/usr/bin"):
                 lib = f"/usr/lib/x86_64-linux-gnu/libpython3.{sys.version_info.minor}.so"
-                if which('ctest'):
-                    logger.critical("To recompile python bindings, use 'cd %s ; source otbenv.profile ; "
-                                    "ctest -S share/otb/swig/build_wrapping.cmake -VV'", prefix)
+                if which("ctest"):
+                    logger.critical(
+                        "To recompile python bindings, use 'cd %s ; source otbenv.profile ; "
+                        "ctest -S share/otb/swig/build_wrapping.cmake -VV'",
+                        prefix,
+                    )
                 elif Path(lib).exists():
                     expect_minor = int(error_message[11])
                     if expect_minor != sys.version_info.minor:
-                        logger.critical("Python library version mismatch (OTB was expecting 3.%s) : "
-                                        "a simple symlink may not work, depending on your python version", expect_minor)
+                        logger.critical(
+                            "Python library version mismatch (OTB was expecting 3.%s) : "
+                            "a simple symlink may not work, depending on your python version",
+                            expect_minor,
+                        )
                     target_lib = f"{prefix}/lib/libpython3.{expect_minor}.so.rh-python3{expect_minor}-1.0"
                     logger.critical("Use 'ln -s %s %s'", lib, target_lib)
                 else:
diff --git a/tests/test_core.py b/tests/test_core.py
index abefc80c7c5a03c06360e900d2e7dabee4e116b3..2e29beefb4a6fb413f532a4a8762bb8849f93874 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -33,6 +33,14 @@ def test_operation():
     assert op.exp == "((im1b1 / 255) * 128);((im1b2 / 255) * 128);((im1b3 / 255) * 128);((im1b4 / 255) * 128)"
 
 
+def test_operation_write_dtype():
+    op = INPUT / 255 * 128
+    assert op.dtype == "float32"
+    op.write("test.tif", pixel_type="uint8")
+    assert Path("test.tif").exists()
+    assert op.dtype == "uint8"
+
+
 def test_sum_bands():
     # Sum of bands
     summed = sum(INPUT[:, :, b] for b in range(INPUT.shape[-1]))
diff --git a/tests/test_numpy.py b/tests/test_numpy.py
index 5b1dd048cb73d2a9c904c3b937c6d60d8b8520c3..878047338fbc6e52e7592b5411e950a5bbea34e4 100644
--- a/tests/test_numpy.py
+++ b/tests/test_numpy.py
@@ -31,7 +31,7 @@ def test_convert_to_array():
 def test_add_noise_array():
     white_noise = np.random.normal(0, 50, size=INPUT.shape)
     noisy_image = INPUT + white_noise
-    assert isinstance(noisy_image, pyotb.otbObject)
+    assert isinstance(noisy_image, pyotb.core.OTBObject)
     assert noisy_image.shape == INPUT.shape
 
 
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index c2d8f0e910851f5412f044c5d4ef887613af57a0..0cf5857d16cb4d4be7d98853bcd39fb862925361 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -11,7 +11,7 @@ OTBAPPS_BLOCKS = [
     # lambda inp: pyotb.ExtractROI({"in": inp, "startx": 10, "starty": 10, "sizex": 50, "sizey": 50}),
     lambda inp: pyotb.ManageNoData({"in": inp, "mode": "changevalue"}),
     lambda inp: pyotb.DynamicConvert({"in": inp}),
-    lambda inp: pyotb.Mosaic({"il": [inp]}),
+    #lambda inp: pyotb.Mosaic({"il": [inp]}),
     lambda inp: pyotb.BandMath({"il": [inp], "exp": "im1b1 + 1"}),
     lambda inp: pyotb.BandMathX({"il": [inp], "exp": "im1"})
 ]
@@ -20,7 +20,7 @@ PYOTB_BLOCKS = [
     lambda inp: 1 + abs(inp) * 2,
     lambda inp: inp[:80, 10:60, :],
 ]
-PIPELINES_LENGTH = [1, 2, 3]
+PIPELINES_LENGTH = [2, 3]
 
 ALL_BLOCKS = PYOTB_BLOCKS + OTBAPPS_BLOCKS
 FILEPATH = os.environ["TEST_INPUT_IMAGE"]