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/otbtf/__init__.py b/otbtf/__init__.py
index 04ac11dbd90b29788484e92e75a2d30fbb167105..cfbcecb4ff2c934847b3f4910920ee6cae9f8de3 100644
--- a/otbtf/__init__.py
+++ b/otbtf/__init__.py
@@ -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/layers.py b/otbtf/layers.py
new file mode 100644
index 0000000000000000000000000000000000000000..a36804210b35f5722c2d7792af625a4c1dd8a2c9
--- /dev/null
+++ b/otbtf/layers.py
@@ -0,0 +1,190 @@
+# -*- 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):
+        """
+        Params:
+            name: layer name
+
+        """
+        super().__init__(name=name)
+
+    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).
+
+        """
+        return tf.expand_dims(tf.math.argmax(inputs, axis=-1), axis=-1)
+
+
+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..4a8d0b9603b2682e1384eb0c2c5c0c646c909e7a
--- /dev/null
+++ b/otbtf/ops.py
@@ -0,0 +1,46 @@
+# -*- 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]
\ No newline at end of file