diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b1dff867cfb6b5e8dbff671a0fe0127b1b554e5b..358a167cc73ac4ffc32ab8bf9420561e4ebe7c9b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,5 @@
 variables:
-  OTBTF_VERSION: 4.1.0
+  OTBTF_VERSION: 4.2.0
   OTB_BUILD: /src/otb/build/OTB/build  # Local OTB build directory
   OTBTF_SRC: /src/otbtf  # Local OTBTF source directory
   OTB_TEST_DIR: $OTB_BUILD/Testing/Temporary  # OTB testing directory
@@ -164,7 +164,7 @@ ctest:
   extends: .tests_base
   stage: Applications Test
   before_script:
-    - pip3 install pytest pytest-cov pytest-order
+    - pip install pytest pytest-cov pytest-order
     - mkdir -p $ARTIFACT_TEST_DIR
     - cd $CI_PROJECT_DIR
 
@@ -189,6 +189,15 @@ sr4rs:
     - export PYTHONPATH=$PYTHONPATH:$PWD/sr4rs
     - python -m pytest --junitxml=$ARTIFACT_TEST_DIR/report_sr4rs.xml $OTBTF_SRC/test/sr4rs_unittest.py
 
+decloud:
+  extends: .applications_test_base
+  script:
+    - git clone https://github.com/CNES/decloud.git
+    - pip install -r $PWD/decloud/docker/requirements.txt
+    - wget -P decloud_data --no-verbose --recursive --level=inf --no-parent -R "index.html*" --cut-dirs=3 --no-host-directories http://indexof.montpellier.irstea.priv/projets/geocicd/decloud/
+    - export DECLOUD_DATA_DIR="$PWD/decloud_data"
+    - pytest decloud/tests/train_from_tfrecords_unittest.py
+
 otbtf_api:
   extends: .applications_test_base
   script:
diff --git a/Dockerfile b/Dockerfile
index 711dc4ba30a70f8985df23d861894921d12b1982..22f9bb19560d25d23b711ad5b593152e8de288d9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,7 +27,9 @@ RUN ln -s /usr/bin/python3 /usr/local/bin/python && ln -s /usr/bin/pip3 /usr/loc
 RUN pip install --no-cache-dir pip --upgrade
 # NumPy version is conflicting with system's gdal dep and may require venv
 ARG NUMPY_SPEC="==1.22.*"
-RUN pip install --no-cache-dir -U wheel mock six future tqdm deprecated "numpy$NUMPY_SPEC" packaging requests \
+# This is to avoid https://github.com/tensorflow/tensorflow/issues/61551
+ARG PROTO_SPEC="==4.23.*"
+RUN pip install --no-cache-dir -U wheel mock six future tqdm deprecated "numpy$NUMPY_SPEC" "protobuf$PROTO_SPEC" packaging requests \
  && pip install --no-cache-dir --no-deps keras_applications keras_preprocessing
 
 # ----------------------------------------------------------------------------
diff --git a/README.md b/README.md
index f4eac4f2e1a8d3fd1af3edaa7a62066f513e05ef..585e8022a0ec66db074e13579edcbdcbe98e7450 100644
--- a/README.md
+++ b/README.md
@@ -33,8 +33,8 @@ The documentation is available on [otbtf.readthedocs.io](https://otbtf.readthedo
 You can use our latest GPU enabled docker images.
 
 ```bash
-docker run --runtime=nvidia -ti mdl4eo/otbtf:4.0.0-gpu otbcli_PatchesExtraction
-docker run --runtime=nvidia -ti mdl4eo/otbtf:4.0.0-gpu python -c "import otbtf"
+docker run --runtime=nvidia -ti mdl4eo/otbtf:4.2.0-gpu otbcli_PatchesExtraction
+docker run --runtime=nvidia -ti mdl4eo/otbtf:4.2.0-gpu python -c "import otbtf"
 ```
 
 You can also build OTBTF from sources (see the documentation)
diff --git a/RELEASE_NOTES.txt b/RELEASE_NOTES.txt
index 9aa374e64e3cf1f23e68a6b026e476b481149c81..97a790cdd5ee6a6fc6d6896314515bb17094c38e 100644
--- a/RELEASE_NOTES.txt
+++ b/RELEASE_NOTES.txt
@@ -1,3 +1,11 @@
+Version 4.2.0 (12 sep 2023)
+----------------------------------------------------------------
+* Add new python modules: `otbtf.layers` (with new classes `DilatedMask`, `ApplyMask`, `ScalarsTile`, `ArgMax`, `Max`) and `otbtf.ops` (`one_hot()`)
+* Fix an error in the documentation
+* Update the otbtf-keras tutorial
+* Add decloud testing in CI
+* Fix protobuf version in dockerfile (see https://github.com/tensorflow/tensorflow/issues/61551)
+
 Version 4.1.0 (23 may 2023)
 ----------------------------------------------------------------
 * Add no-data values support for inference in TensorflowModelServe application
diff --git a/doc/api_tutorial.md b/doc/api_tutorial.md
index bcd6ea45b21f295e2e7a0c90b9fb97064e61e689..cc08b9198469a97a083c16588976a9b2c9aa8785 100644
--- a/doc/api_tutorial.md
+++ b/doc/api_tutorial.md
@@ -184,6 +184,19 @@ def dataset_preprocessing_fn(examples: dict):
 
 As you can see, we don't modify the input tensor, since we want to use it 
 as it in the model.
+Note that since version 4.2.0 the `otbtf.ops.one_hot` can ease the transform:
+
+```python
+def dataset_preprocessing_fn(examples: dict):
+    return {
+        INPUT_NAME: examples["input_xs_patches"],
+        TARGET_NAME: otbtf.ops.one_hot(
+            labels=examples["labels_patches"],
+            nb_classes=N_CLASSES
+        )
+}
+
+```
 
 ### Model inputs preprocessing
 
@@ -258,10 +271,7 @@ and the estimated values.
         out_tconv1 = _tconv(out_conv4, 64, "tconv1") + out_conv3
         out_tconv2 = _tconv(out_tconv1, 32, "tconv2") + out_conv2
         out_tconv3 = _tconv(out_tconv2, 16, "tconv3") + out_conv1
-        out_tconv4 = _tconv(out_tconv3, N_CLASSES, "classifier", None)
-
-        softmax_op = tf.keras.layers.Softmax(name=OUTPUT_SOFTMAX_NAME)
-        predictions = softmax_op(out_tconv4)
+        predictions = _tconv(out_tconv3, N_CLASSES, OUTPUT_SOFTMAX_NAME, "softmax")
 
         return {TARGET_NAME: predictions}
 
@@ -375,32 +385,39 @@ polluted by the convolutional padding.
 For a 2D convolution of stride \(s\) and kernel size \(k\), we can deduce the 
 valid output size \(y\) from input size \(x\) using this expression:
 $$
-y = \left[\frac{x - k + 1}{s}\right]
+y = \left[\frac{x - k }{s}\right] + 1
 $$
 For a 2D transposed convolution of stride \(s\) and kernel size \(k\), we can 
 deduce the valid output size \(y\) from input size \(x\) using this expression:
 $$
-y = (x * s) - k + 1
+y = x * s - k + 2
 $$
 
-Let's consider a chunk of input image of size 128, and check the valid output 
+Let's consider a chunk of input image of size 64, and check the valid output 
 size of our model:
 
-| Conv. name | Conv. type        | Kernel | Stride | Out. size | Valid out. size |
-|------------|-------------------|--------|--------|-----------|-----------------|
-| *conv1*    | Conv2D            | 3      | 2      | 64        | 63              |
-| *conv2*    | Conv2D            | 3      | 2      | 32        | 30              |
-| *conv3*    | Conv2D            | 3      | 2      | 16        | 14              |
-| *conv4*    | Conv2D            | 3      | 2      | 8         | 6               |
-| *tconv1*   | Transposed Conv2D | 3      | 2      | 16        | 10              |
-| *tconv2*   | Transposed Conv2D | 3      | 2      | 32        | 18              |
-| *tconv3*   | Transposed Conv2D | 3      | 2      | 64        | 34              |
+| Conv. name     | Conv. type        | Kernel | Stride | Out. size | Valid out. size |
+|----------------|-------------------|--------|--------|-----------|-----------------|
+| *input*        | /                 | /      | /      | 64        | 64              |
+| *conv1*        | Conv2D            | 3      | 2      | 32        | 31              |
+| *conv2*        | Conv2D            | 3      | 2      | 16        | 15              |
+| *conv3*        | Conv2D            | 3      | 2      | 8         | 7               |
+| *conv4*        | Conv2D            | 3      | 2      | 4         | 3               |
+| *tconv1*       | Transposed Conv2D | 3      | 2      | 8         | 5               |
+| *tconv2*       | Transposed Conv2D | 3      | 2      | 16        | 9               |
+| *tconv3*       | Transposed Conv2D | 3      | 2      | 32        | 17              |
+| *classifier*   | Transposed Conv2D | 3      | 2      | 64        | 33              |
 
 This shows that our model can be applied in a fully convolutional fashion 
 without generating blocking artifacts, using the central part of the output of 
-size 34. This is equivalent to remove \((128 - 24)/2 = 47\) pixels from 
-the borders of the output. We can hence use the output cropped with **64** 
-pixels, named ***predictions_crop64***.
+size 33. This is equivalent to remove \((64 - 33)/2 = 15\) pixels from 
+the borders of the output. We keep the upper nearest power of 2 to keep the 
+convolutions consistent between two adjacent image chunks, hence we can remove 16 
+pixels from the borders. We can hence use the output cropped with **16** pixels, 
+named ***predictions_crop16*** in the model outputs.
+By default, cropped outputs in `otbtf.ModelBase` are generated for the following 
+values: `[16, 32, 64, 96, 128]` but that can be changed setting `inference_cropping` 
+in the model `__init__()` (see the reference API documentation for details).
 
 !!! Info
 
@@ -427,10 +444,11 @@ In the following subsections, we run `TensorflowModelServe` over the input
 image, with the following parameters:
 
 - the input name is ***input_xs***
-- the output name is ***predictions_crop64*** (cropping margin of 64 pixels)
-- we choose a receptive field of ***256*** and an expression field of 
-***128*** so that they match the cropping margin of 64 pixels. 
-
+- the output name is ***predictions_crop16*** (cropping margin of 16 pixels)
+- we choose a receptive field of ***64*** and an expression field of 
+***32*** so that they match the cropping margin of 16 pixels (since we remove 
+16 pixels from each side in x and y dimensions, we remove a total of 32 pixels 
+from each borders in x/y dimensions). 
 
 ### Command Line Interface
 
@@ -439,14 +457,14 @@ Open a terminal and run the following command:
 ```commandline
 otbcli_TensorflowModelServe \
 -source1.il $DATADIR/fake_spot6.jp2 \
--source1.rfieldx 256 \ 
--source1.rfieldy 256 \
+-source1.rfieldx 64 \ 
+-source1.rfieldy 64 \
 -source1.placeholder "input_xs" \
 -model.dir /tmp/my_1st_savedmodel \
 -model.fullyconv on \
--output.names "predictions_crop64" \
--output.efieldx 128 \
--output.efieldy 128 \
+-output.names "predictions_crop16" \
+-output.efieldx 32 \
+-output.efieldy 32 \
 -out softmax.tif
 ```
 
@@ -459,14 +477,14 @@ python wrapper:
 import otbApplication
 app = otbApplication.Registry.CreateApplication("TensorflowModelServe")
 app.SetParameterStringList("source1.il", ["fake_spot6.jp2"])
-app.SetParameterInt("source1.rfieldx", 256)
-app.SetParameterInt("source1.rfieldy", 256)
+app.SetParameterInt("source1.rfieldx", 64)
+app.SetParameterInt("source1.rfieldy", 64)
 app.SetParameterString("source1.placeholder", "input_xs")
 app.SetParameterString("model.dir", "/tmp/my_1st_savedmodel")
 app.EnableParameter("fullyconv")
-app.SetParameterStringList("output.names", ["predictions_crop64"])
-app.SetParameterInt("output.efieldx", 128)
-app.SetParameterInt("output.efieldy", 128)
+app.SetParameterStringList("output.names", ["predictions_crop16"])
+app.SetParameterInt("output.efieldx", 32)
+app.SetParameterInt("output.efieldy", 32)
 app.SetParameterString("out", "softmax.tif")
 app.ExecuteAndWriteOutput()
 ```
@@ -479,14 +497,14 @@ Using PyOTB is nicer:
 import pyotb
 pyotb.TensorflowModelServe({
     "source1.il": "fake_spot6.jp2",
-    "source1.rfieldx": 256,
-    "source1.rfieldy": 256,
+    "source1.rfieldx": 64,
+    "source1.rfieldy": 64,
     "source1.placeholder": "input_xs",
     "model.dir": "/tmp/my_1st_savedmodel",
     "fullyconv": True,
-    "output.names": ["predictions_crop64"],
-    "output.efieldx": 128,
-    "output.efieldy": 128,
+    "output.names": ["predictions_crop16"],
+    "output.efieldx": 32,
+    "output.efieldy": 32,
     "out": "softmax.tif",
 })
 ```
@@ -499,4 +517,4 @@ pyotb.TensorflowModelServe({
     control the output image chunk size and tiling/stripping layout. Combined 
     with the `optim` parameters, you will likely always find the best settings 
     suited for the hardware. Also, the receptive and expression fields sizes 
-    have a major contribution.
\ No newline at end of file
+    have a major contribution.
diff --git a/doc/docker_troubleshooting.md b/doc/docker_troubleshooting.md
index c34b7d2d998f1cc6595c0f8e5a80a05809732802..4aa0d506d32aa42c264d580e5362fe37aa72e229 100644
--- a/doc/docker_troubleshooting.md
+++ b/doc/docker_troubleshooting.md
@@ -52,13 +52,13 @@ sudo service docker {status,enable,disable,start,stop,restart}
 Run a simple command in a one-shot container:
 
 ```bash
-docker run mdl4eo/otbtf:3.4.0-cpu otbcli_PatchesExtraction
+docker run mdl4eo/otbtf:4.2.0-cpu otbcli_PatchesExtraction
 ```
 
 You can also use the image in interactive mode with bash:
 
 ```bash
-docker run -ti mdl4eo/otbtf:3.4.0-cpu bash
+docker run -ti mdl4eo/otbtf:4.2.0-cpu bash
 ```
 
 ### Mounting file systems
@@ -70,7 +70,7 @@ to use inside the container:
 The following command shows you how to access the folder from the docker image.
 
 ```bash
-docker run -v /mnt/disk1/:/data/ -ti mdl4eo/otbtf:3.4.0-cpu bash -c "ls /data"
+docker run -v /mnt/disk1/:/data/ -ti mdl4eo/otbtf:4.2.0-cpu bash -c "ls /data"
 ```
 Beware of ownership issues! see the last section of this doc.
 
@@ -81,7 +81,7 @@ any directory.
 
 ```bash
 docker create --interactive --tty --volume /home/$USER:/home/otbuser/ \
-    --name otbtf mdl4eo/otbtf:3.4.0-cpu /bin/bash
+    --name otbtf mdl4eo/otbtf:4.2.0-cpu /bin/bash
 ```
 
 !!! warning
@@ -160,7 +160,7 @@ automatically pull image
 
 ```bash
 docker create --interactive --tty --volume /home/$USER:/home/otbuser \
-    --name otbtf mdl4eo/otbtf:3.4.0-cpu /bin/bash
+    --name otbtf mdl4eo/otbtf:4.2.0-cpu /bin/bash
 ```
 
 Start a background container process:
diff --git a/doc/docker_use.md b/doc/docker_use.md
index ebbab5a492f122be2e690c1497544c31a2e4d708..f7b81683f0d076d36b2a2044cf704eca6286945e 100644
--- a/doc/docker_use.md
+++ b/doc/docker_use.md
@@ -5,13 +5,13 @@ We recommend to use OTBTF from official docker images.
 Latest CPU-only docker image:
 
 ```commandline
-docker pull mdl4eo/otbtf:4.0.0-cpu
+docker pull mdl4eo/otbtf:4.2.0-cpu
 ```
 
 Latest GPU-ready docker image:
 
 ```commandline
-docker pull mdl4eo/otbtf:4.0.0-gpu
+docker pull mdl4eo/otbtf:4.2.0-gpu
 ```
 
 Read more in the following sections.
@@ -25,12 +25,12 @@ Since OTBTF >= 3.2.1 you can find the latest docker images on
 
 | Name                                                                               | Os            | TF    | OTB   | Description            | Dev files | Compute capability |
 |------------------------------------------------------------------------------------| ------------- |-------|-------| ---------------------- | --------- | ------------------ |
-| **mdl4eo/otbtf:4.0.0-cpu**                                                         | Ubuntu Jammy  | r2.12 | 8.1.0 | CPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
-| **mdl4eo/otbtf:4.0.0-cpu-dev**                                                     | Ubuntu Jammy  | r2.12 | 8.1.0 | CPU, no optimization (dev) |  yes  | 5.2,6.1,7.0,7.5,8.6|
-| **mdl4eo/otbtf:4.0.0-gpu**                                                         | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
-| **mdl4eo/otbtf:4.0.0-gpu-dev**                                                     | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU, no optimization (dev) | yes   | 5.2,6.1,7.0,7.5,8.6|
-| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.0.0-gpu-opt**     | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU with opt.          | no        | 5.2,6.1,7.0,7.5,8.6|
-| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.0.0-gpu-opt-dev** | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU with opt. (dev)    | yes       | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.2.0-cpu**                                                         | Ubuntu Jammy  | r2.12 | 8.1.0 | CPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.2.0-cpu-dev**                                                     | Ubuntu Jammy  | r2.12 | 8.1.0 | CPU, no optimization (dev) |  yes  | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.2.0-gpu**                                                         | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.2.0-gpu-dev**                                                     | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU, no optimization (dev) | yes   | 5.2,6.1,7.0,7.5,8.6|
+| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.2.0-gpu-opt**     | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU with opt.          | no        | 5.2,6.1,7.0,7.5,8.6|
+| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.2.0-gpu-opt-dev** | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU with opt. (dev)    | yes       | 5.2,6.1,7.0,7.5,8.6|
 
 The list of older releases is available [here](#older-images).
 
@@ -51,13 +51,13 @@ You can then use the OTBTF `gpu` tagged docker images with the **NVIDIA runtime*
 With Docker version earlier than 19.03 :
 
 ```bash
-docker run --runtime=nvidia -ti mdl4eo/otbtf:4.0.0-gpu bash
+docker run --runtime=nvidia -ti mdl4eo/otbtf:4.2.0-gpu bash
 ```
 
 With Docker version including and after 19.03 :
 
 ```bash
-docker run --gpus all -ti mdl4eo/otbtf:4.0.0-gpu bash
+docker run --gpus all -ti mdl4eo/otbtf:4.2.0-gpu bash
 ```
 
 You can find some details on the **GPU docker image** and some **docker tips 
@@ -80,7 +80,7 @@ See here how to install docker on Ubuntu
 1. Install [WSL2](https://docs.microsoft.com/en-us/windows/wsl/install-win10#manual-installation-steps) (Windows Subsystem for Linux)
 2. Install [docker desktop](https://www.docker.com/products/docker-desktop)
 3. Start **docker desktop** and **enable WSL2** from *Settings* > *General* then tick the box *Use the WSL2 based engine*
-3. Open a **cmd.exe** or **PowerShell** terminal, and type `docker create --name otbtf-cpu --interactive --tty mdl4eo/otbtf:4.0.0-cpu`
+3. Open a **cmd.exe** or **PowerShell** terminal, and type `docker create --name otbtf-cpu --interactive --tty mdl4eo/otbtf:4.2.0-cpu`
 4. Open **docker desktop**, and check that the docker is running in the **Container/Apps** menu
 ![Docker desktop, after the docker image is downloaded and ready to use](images/docker_desktop_1.jpeg)
 5. From **docker desktop**, click on the icon highlighted as shown below, and use the bash terminal that should pop up!
@@ -160,4 +160,16 @@ Here you can find the list of older releases of OTBTF:
 | **mdl4eo/otbtf:3.4.0-gpu-dev**                                                     | Ubuntu Focal  | r2.8   | 8.1.0 | GPU, no optimization (dev) | yes   | 5.2,6.1,7.0,7.5,8.6|
 | **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:3.4.0-gpu-opt**     | Ubuntu Focal  | r2.8   | 8.1.0 | GPU with opt.          | no        | 5.2,6.1,7.0,7.5,8.6|
 | **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:3.4.0-gpu-opt-dev** | Ubuntu Focal  | r2.8   | 8.1.0 | GPU with opt. (dev)    | yes       | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.0.0-cpu**                                                         | Ubuntu Jammy  | r2.12  | 8.1.0 | CPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.0.0-cpu-dev**                                                     | Ubuntu Jammy  | r2.12  | 8.1.0 | CPU, no optimization (dev) |  yes  | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.0.0-gpu**                                                         | Ubuntu Jammy  | r2.12  | 8.1.0 | GPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.0.0-gpu-dev**                                                     | Ubuntu Jammy  | r2.12  | 8.1.0 | GPU, no optimization (dev) | yes   | 5.2,6.1,7.0,7.5,8.6|
+| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.0.0-gpu-opt**     | Ubuntu Jammy  | r2.12  | 8.1.0 | GPU with opt.          | no        | 5.2,6.1,7.0,7.5,8.6|
+| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.0.0-gpu-opt-dev** | Ubuntu Jammy  | r2.12  | 8.1.0 | GPU with opt. (dev)    | yes       | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.1.0-cpu**                                                         | Ubuntu Jammy  | r2.12 | 8.1.0 | CPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.1.0-cpu-dev**                                                     | Ubuntu Jammy  | r2.12 | 8.1.0 | CPU, no optimization (dev) |  yes  | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.1.0-gpu**                                                         | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU, no optimization   | no        | 5.2,6.1,7.0,7.5,8.6|
+| **mdl4eo/otbtf:4.1.0-gpu-dev**                                                     | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU, no optimization (dev) | yes   | 5.2,6.1,7.0,7.5,8.6|
+| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.1.0-gpu-opt**     | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU with opt.          | no        | 5.2,6.1,7.0,7.5,8.6|
+| **gitlab.irstea.fr/remi.cresson/otbtf/container_registry/otbtf:4.1.0-gpu-opt-dev** | Ubuntu Jammy  | r2.12 | 8.1.0 | GPU with opt. (dev)    | yes       | 5.2,6.1,7.0,7.5,8.6|
 
diff --git a/otbtf/__init__.py b/otbtf/__init__.py
index 04ac11dbd90b29788484e92e75a2d30fbb167105..1ce624226bcf921418b5de8731eb1566d1486055 100644
--- a/otbtf/__init__.py
+++ b/otbtf/__init__.py
@@ -2,7 +2,7 @@
 # ==========================================================================
 #
 #   Copyright 2018-2019 IRSTEA
-#   Copyright 2020-2022 INRAE
+#   Copyright 2020-2023 INRAE
 #
 #   Licensed under the Apache License, Version 2.0 (the "License");
 #   you may not use this file except in compliance with the License.
@@ -33,4 +33,5 @@ except ImportError:
 
 from otbtf.tfrecords import TFRecords  # noqa
 from otbtf.model import ModelBase  # noqa
+from otbtf import layers, ops  # noqa
 __version__ = pkg_resources.require("otbtf")[0].version
diff --git a/otbtf/examples/tensorflow_v2x/fcnn/fcnn_model.py b/otbtf/examples/tensorflow_v2x/fcnn/fcnn_model.py
index 44285d92bb77695224288cb9d804018c90924c82..fcd14a2024e575f79a9f0c9a4a8e475a5e1d373b 100644
--- a/otbtf/examples/tensorflow_v2x/fcnn/fcnn_model.py
+++ b/otbtf/examples/tensorflow_v2x/fcnn/fcnn_model.py
@@ -123,6 +123,18 @@ class FCNNModel(ModelBase):
         softmax_op = tf.keras.layers.Softmax(name=OUTPUT_SOFTMAX_NAME)
         predictions = softmax_op(out_tconv4)
 
+        # note that we could also add additional outputs, for instance the
+        # argmax of the softmax:
+        #
+        # argmax_op = otbtf.layers.Argmax(name="labels")
+        # labels = argmax_op(predictions)
+        # return {TARGET_NAME: predictions, OUTPUT_ARGMAX_NAME: labels}
+        # The default extra outputs (i.e. output tensors with cropping in
+        # physical domain) are append by `otbtf.ModelBase` for all returned
+        # outputs of this function to be used at inference time (e.g.
+        # "labels_crop32", "labels_crop64", ...,
+        # "predictions_softmax_tensor_crop16", ..., etc).
+
         return {TARGET_NAME: predictions}
 
 
@@ -173,12 +185,23 @@ def train(params, ds_train, ds_valid, ds_test):
         model = FCNNModel(dataset_element_spec=ds_train.element_spec)
 
         # Compile the model
+        # It is a good practice to use a `dict` to explicitly name the outputs
+        # over which the losses/metrics are computed.
+        # This ensures a better optimization control, and also avoids lots of
+        # useless outputs (e.g. metrics computed over extra outputs).
         model.compile(
-            loss=tf.keras.losses.CategoricalCrossentropy(),
+            loss={
+                TARGET_NAME: tf.keras.losses.CategoricalCrossentropy()
+            },
             optimizer=tf.keras.optimizers.Adam(
                 learning_rate=params.learning_rate
             ),
-            metrics=[tf.keras.metrics.Precision(), tf.keras.metrics.Recall()]
+            metrics={
+                TARGET_NAME: [
+                    tf.keras.metrics.Precision(class_id=1),
+                    tf.keras.metrics.Recall(class_id=1)
+                ]
+            }
         )
 
         # Summarize the model (in CLI)
diff --git a/otbtf/layers.py b/otbtf/layers.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef65ec1c5ce593ca70d6c316ae018f6a46efa596
--- /dev/null
+++ b/otbtf/layers.py
@@ -0,0 +1,195 @@
+# -*- coding: utf-8 -*-
+# ==========================================================================
+#
+#   Copyright 2018-2019 IRSTEA
+#   Copyright 2020-2023 INRAE
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#          http://www.apache.org/licenses/LICENSE-2.0.txt
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+# ==========================================================================*/
+"""
+[Source code :fontawesome-brands-github:](https://github.com/remicres/otbtf/
+tree/master/otbtf/layers.py){ .md-button }
+
+The utils module provides some useful keras layers to build deep nets.
+"""
+from typing import List, Tuple, Any
+import tensorflow as tf
+
+
+Tensor = Any
+Scalars = List[float] | Tuple[float]
+
+
+class DilatedMask(tf.keras.layers.Layer):
+    """Layer to dilate a binary mask."""
+    def __init__(self, nodata_value: float, radius: int, name: str = None):
+        """
+        Params:
+            nodata_value: the no-data value of the binary mask
+            radius: dilatation radius
+            name: layer name
+
+        """
+        self.nodata_value = nodata_value
+        self.radius = radius
+        super().__init__(name=name)
+
+    def call(self, inp: Tensor):
+        """
+        Params:
+            inp: input layer
+
+        """
+        # Compute a binary mask from the input
+        nodata_mask = tf.cast(tf.math.equal(inp, self.nodata_value), tf.uint8)
+
+        se_size = 1 + 2 * self.radius
+        # Create a morphological kernel suitable for binary dilatation, see
+        # https://stackoverflow.com/q/54686895/13711499
+        kernel = tf.zeros((se_size, se_size, 1), dtype=tf.uint8)
+        conv2d_out = tf.nn.dilation2d(
+            input=nodata_mask,
+            filters=kernel,
+            strides=[1, 1, 1, 1],
+            padding="SAME",
+            data_format="NHWC",
+            dilations=[1, 1, 1, 1],
+            name="dilatation_conv2d"
+        )
+        return tf.cast(conv2d_out, tf.uint8)
+
+
+class ApplyMask(tf.keras.layers.Layer):
+    """Layer to apply a binary mask to one input."""
+    def __init__(self, out_nodata: float, name: str = None):
+        """
+        Params:
+            out_nodata: output no-data value, set when the mask is 1
+            name: layer name
+
+        """
+        super().__init__(name=name)
+        self.out_nodata = out_nodata
+
+    def call(self, inputs: Tuple[Tensor] | List[Tensor]):
+        """
+        Params:
+            inputs: (mask, input). list or tuple of size 2. First element is
+                the binary mask, second element is the input. In the binary
+                mask, values at 1 indicate where to replace input values with
+                no-data.
+
+        """
+        mask, inp = inputs
+        return tf.where(mask == 1, float(self.out_nodata), inp)
+
+
+class ScalarsTile(tf.keras.layers.Layer):
+    """
+    Layer to duplicate some scalars in a whole array.
+    Simple example with only one scalar = 0.152:
+        output [[0.152, 0.152, 0.152],
+                [0.152, 0.152, 0.152],
+                [0.152, 0.152, 0.152]]
+
+    """
+    def __init__(self, name: str = None):
+        """
+        Params:
+            name: layer name
+
+        """
+        super().__init__(name=name)
+
+    def call(self, inputs: List[Tensor | Scalars] | Tuple[Tensor | Scalars]):
+        """
+        Params:
+            inputs: [reference, scalar inputs]. Reference is the tensor whose
+                shape has to be matched, is expected to be of shape [x, y, n].
+                scalar inputs are expected to be of shape [1] or [n] so that
+                they fill the last dimension of the output.
+
+        """
+        ref, scalar_inputs = inputs
+        inp = tf.stack(scalar_inputs, axis=-1)
+        inp = tf.expand_dims(tf.expand_dims(inp, axis=1), axis=1)
+        return tf.tile(inp, [1, tf.shape(ref)[1], tf.shape(ref)[2], 1])
+
+
+class Argmax(tf.keras.layers.Layer):
+    """
+    Layer to compute the argmax of a tensor.
+
+    For example, for a vector A=[0.1, 0.3, 0.6], the output is 2 because
+    A[2] is the max.
+    Useful to transform a softmax into a "categorical" map for instance.
+
+    """
+    def __init__(self, name: str = None, expand_last_dim: bool = True):
+        """
+        Params:
+            name: layer name
+            expand_last_dim: expand the last dimension when True
+
+        """
+        super().__init__(name=name)
+        self.expand_last_dim = expand_last_dim
+
+    def call(self, inputs):
+        """
+        Params:
+            inputs: softmax tensor, or any tensor with last dimension of
+                size nb_classes
+
+        Returns:
+            Index of the maximum value, in the last dimension. Int32.
+            The output tensor has same shape length as input, but with last
+            dimension of size 1. Contains integer values ranging from 0 to
+            (nb_classes - 1).
+
+        """
+        argmax = tf.math.argmax(inputs, axis=-1)
+        if self.expand_last_dim:
+            return tf.expand_dims(argmax, axis=-1)
+        return argmax
+
+
+class Max(tf.keras.layers.Layer):
+    """
+    Layer to compute the max of a tensor.
+
+    For example, for a vector [0.1, 0.3, 0.6], the output is 0.6
+    Useful to transform a softmax into a "confidence" map for instance
+
+    """
+    def __init__(self, name=None):
+        """
+        Params:
+            name: layer name
+
+        """
+        super().__init__(name=name)
+
+    def call(self, inputs):
+        """
+        Params:
+            inputs: softmax tensor
+
+        Returns:
+            Maximum value along the last axis of the input.
+            The output tensor has same shape length as input, but with last
+            dimension of size 1.
+
+        """
+        return tf.expand_dims(tf.math.reduce_max(inputs, axis=-1), axis=-1)
diff --git a/otbtf/model.py b/otbtf/model.py
index b3ee7b92470cc7e3c91eac01fbce63e364e14345..9958510bdf32dd147df273a264714c93d2543ee1 100644
--- a/otbtf/model.py
+++ b/otbtf/model.py
@@ -28,7 +28,8 @@ import abc
 import logging
 import tensorflow as tf
 
-TensorsDict = Dict[str, Any]
+Tensor = Any
+TensorsDict = Dict[str, Tensor]
 
 
 class ModelBase(abc.ABC):
diff --git a/otbtf/ops.py b/otbtf/ops.py
new file mode 100644
index 0000000000000000000000000000000000000000..ef5c52b94060833c568dfabfcd54e98103ac85aa
--- /dev/null
+++ b/otbtf/ops.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# ==========================================================================
+#
+#   Copyright 2018-2019 IRSTEA
+#   Copyright 2020-2023 INRAE
+#
+#   Licensed under the Apache License, Version 2.0 (the "License");
+#   you may not use this file except in compliance with the License.
+#   You may obtain a copy of the License at
+#
+#          http://www.apache.org/licenses/LICENSE-2.0.txt
+#
+#   Unless required by applicable law or agreed to in writing, software
+#   distributed under the License is distributed on an "AS IS" BASIS,
+#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#   See the License for the specific language governing permissions and
+#   limitations under the License.
+#
+# ==========================================================================*/
+"""
+[Source code :fontawesome-brands-github:](https://github.com/remicres/otbtf/
+tree/master/otbtf/ops.py){ .md-button }
+
+The utils module provides some useful Tensorflow ad keras operators to build
+and train deep nets.
+"""
+from typing import List, Tuple, Any
+import tensorflow as tf
+
+
+Tensor = Any
+Scalars = List[float] | Tuple[float]
+
+
+def one_hot(labels: Tensor, nb_classes: int):
+    """
+    Converts labels values into one-hot vector.
+
+    Params:
+        labels: tensor of label values (shape [x, y, 1])
+        nb_classes: number of classes
+
+    Returns:
+        one-hot encoded vector (shape [x, y, nb_classes])
+
+    """
+    labels_xy = tf.squeeze(tf.cast(labels, tf.int32), axis=-1)  # shape [x, y]
+    return tf.one_hot(labels_xy, depth=nb_classes)  # shape [x, y, nb_classes]
diff --git a/setup.py b/setup.py
index 1feeff9c270b86fa03cb7c43ca00956cac4de97f..3222afc47e5bf2ea646222d7c6926412a4b300ed 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
 
 setuptools.setup(
     name="otbtf",
-    version="4.1.0",
+    version="4.2.0",
     author="Remi Cresson",
     author_email="remi.cresson@inrae.fr",
     description="OTBTF: Orfeo ToolBox meets TensorFlow",
@@ -14,11 +14,9 @@ setuptools.setup(
     long_description_content_type="text/markdown",
     url="https://gitlab.irstea.fr/remi.cresson/otbtf",
     classifiers=[
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
         "Topic :: Scientific/Engineering :: GIS",
         "Topic :: Scientific/Engineering :: Image Processing",
         "License :: OSI Approved :: Apache Software License",