Repository: fish-quant/big-fish (language: python)

Issue:
Documentation sphinx
Refactor docstring and add a sphinx documentation to the package.


Discussion / hints:

Fix patch:
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 00000000..c6457d37
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,23 @@
+# .readthedocs.yaml
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Build documentation in the docs/ directory with Sphinx
+sphinx:
+   builder: html
+   configuration: docs/source/conf.py
+
+# Optionally build your docs in additional formats such as PDF
+formats:
+   - pdf
+
+# Optionally set the version of Python and requirements required to build your docs
+python:
+   version: 3.6
+   install:
+   - requirements: requirements_docs.txt
+   - method: pip
+     path: .
\ No newline at end of file
diff --git a/README.md b/README.md
index c7328e9c..61ac261c 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,7 @@
 # Big-FISH
 
 [![Build Status](https://travis-ci.com/fish-quant/big-fish.svg?branch=master)](https://travis-ci.com/fish-quant/big-fish)
+[![Documentation Status](https://readthedocs.org/projects/big-fish/badge/?version=latest)](https://big-fish.readthedocs.io/en/latest/?badge=latest)
 [![codecov](https://codecov.io/gh/fish-quant/big-fish/branch/master/graph/badge.svg)](https://codecov.io/gh/fish-quant/big-fish)
 [![License](https://img.shields.io/badge/license-BSD%203--Clause-green)](https://github.com/fish-quant/big-fish/blob/master/LICENSE)
 [![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads/release/python-360/)
@@ -25,7 +26,11 @@ Big-FISH requires Python 3.6 or newer. Additionally, it has the following depend
 - pandas (== 0.24.0)
 - mrc (== 0.1.5)
 
-Updated dependencies might break.
+For segmentation purpose, two additional dependencies can be requested:
+- tensorflow (== 2.3.0)
+- tensorflow-addons (== 0.12.1)
+
+Updated dependencies are not tested yet and might break.
 
 ### Virtual environment
 
@@ -58,15 +63,9 @@ pip install .
 
 ## Usage
 
-Big-FISH provides a toolbox for the full analysis pipeline of smFISH images:
-
-- Use `bigfish.stack` subpackage for I/O operations, preprocessing and postprocessing.
-- Use `bigfish.segmentation` subpackage for nucleus and cell segmentation.
-- Use `bigfish.detection` subpackage for mRNAs detection.
-- Use `bigfish.plot` subpackage for plotting routines.
-- Use `bigfish.classification` subpackage for pattern recognition tasks.
+Big-FISH provides a toolbox for the full analysis pipeline of smFISH images. A complete [documentation](https://big-fish.readthedocs.io/en/stable/) is available online. 
 
-Several examples are available as [Jupyter notebooks](https://github.com/fish-quant/big-fish-examples/tree/master/notebooks).
+This package is part of the [FISH-Quant](https://fish-quant.github.io/) framework and several examples are also available as [Jupyter notebooks](https://github.com/fish-quant/big-fish-examples/tree/master/notebooks).
 
 ## Support
 
@@ -74,13 +73,8 @@ If you have any question relative to the repository, please open an [issue](http
 
 ## Roadmap (suggestion)
 
-Version 0.5.0:
-- Switch to tensorflow 2.2.0.
-- Integrate a deep learning model for segmentation.
-
 Version 1.0.0:
 - Complete code coverage.
-- Add sphinx documentation.
 
 ## Development
 
diff --git a/bigfish/__init__.py b/bigfish/__init__.py
index 22b1dc83..814ab7c4 100644
--- a/bigfish/__init__.py
+++ b/bigfish/__init__.py
@@ -12,4 +12,4 @@
 # MINOR: new features
 # PATCH: backwards compatible bug fixes
 # MAJOR.MINOR.PATCHdev means a version under development
-__version__ = "0.4.0"
+__version__ = "0.5.0"
diff --git a/bigfish/classification/features.py b/bigfish/classification/features.py
index 73b5e297..6e8c3846 100644
--- a/bigfish/classification/features.py
+++ b/bigfish/classification/features.py
@@ -281,7 +281,7 @@ def get_features_name(names_features_distance=False,
     if names_features_dispersion:
         features_name += ["index_polarization",
                           "index_dispersion",
-                          "index_peripheral_dispersion"]
+                          "index_peripheral_distribution"]
 
     if names_features_topography:
         features_name += ["index_rna_nuc_edge",
@@ -525,10 +525,10 @@ def features_protrusion(rna_coord, cell_mask, nuc_mask, ndim, voxel_size_yx,
 
 def features_dispersion(smfish, rna_coord, centroid_rna, cell_mask,
                         centroid_cell, centroid_nuc, ndim, check_input=True):
-    """Compute RNA Distribution Index features (RDI) described in
+    """Compute RNA Distribution Index features (RDI) described in:
 
     RDI Calculator: An analysis Tool to assess RNA distributions in cells,
-    Stueland M., Wang T., Park H. Y.,  Mili, S., 201
+    Stueland M., Wang T., Park H. Y., Mili, S., 2019.
 
     Parameters
     ----------
@@ -556,8 +556,8 @@ def features_dispersion(smfish, rna_coord, centroid_rna, cell_mask,
         Polarization index (PI).
     index_dispersion : float
         Dispersion index (DI).
-    index_peripheral_dispersion : float
-        Peripheral dispersion index (PDI).
+    index_peripheral_distribution : float
+        Peripheral distribution index (PDI).
 
     """
     # check parameters
@@ -616,14 +616,14 @@ def features_dispersion(smfish, rna_coord, centroid_rna, cell_mask,
 
     features += (index_dispersion,)
 
-    # compute peripheral dispersion index
+    # compute peripheral distribution index
     r = np.linalg.norm(rna_coord_ - centroid_nuc, axis=1) ** 2
     a = np.sum((r * rna_value) / total_intensity_rna)
     r = np.linalg.norm(cell_coord - centroid_nuc, axis=1) ** 2
     b = np.sum((r * cell_value) / total_intensity_cell)
-    index_peripheral_dispersion = a / b
+    index_peripheral_distribution = a / b
 
-    features += (index_peripheral_dispersion,)
+    features += (index_peripheral_distribution,)
 
     return features
 
@@ -693,8 +693,8 @@ def features_topography(rna_coord, cell_mask, nuc_mask, cell_mask_out_nuc,
         Six regions are targeted (0-500nm, 500-1000nm, 1000-1500nm,
         1500-2000nm, 2000-2500nm and 2500-3000nm from the cell membrane).
     proportion_rna_cell_marge : float
-        Proportion of RNAs detected in a specific region around around cell
-        membrane. Six regions are targeted (0-500nm, 500-1000nm, 1000-1500nm,
+        Proportion of RNAs detected in a specific region around cell membrane.
+        Six regions are targeted (0-500nm, 500-1000nm, 1000-1500nm,
         1500-2000nm, 2000-2500nm and 2500-3000nm from the cell membrane).
 
     """
@@ -920,7 +920,7 @@ def features_centrosome(smfish, rna_coord, distance_centrosome, cell_mask,
         Proportion of RNAs within a 2000nm radius from a centrosome.
     index_centrosome_dispersion : float
         Centrosomal dispersion index. It quantify the dispersion of RNAs
-        around centrosmes. The lower, the closer the RNAs are.
+        around centrosomes. The lower, the closer the RNAs are.
 
     """
     # check parameters
diff --git a/bigfish/classification/input_preparation.py b/bigfish/classification/input_preparation.py
index fc5ab04d..a99def10 100644
--- a/bigfish/classification/input_preparation.py
+++ b/bigfish/classification/input_preparation.py
@@ -6,8 +6,6 @@
 Functions to prepare input data.
 """
 
-import threading
-
 import numpy as np
 from scipy import ndimage as ndi
 
@@ -30,7 +28,7 @@ def prepare_extracted_data(cell_mask, nuc_mask=None, ndim=None, rna_coord=None,
         Surface of the nucleus with shape (y, x).
     ndim : int
         Number of spatial dimensions to consider (2 or 3). Mandatory if
-        'rna_coord' is provided.
+        `rna_coord` is provided.
     rna_coord : np.ndarray, np.int64
         Coordinates of the detected spots with shape (nb_spots, 4) or
         (nb_spots, 3). One coordinate per dimension (zyx or yx dimensions)
@@ -320,439 +318,3 @@ def _get_centrosome_distance_map(centrosome_coord, cell_mask):
     distance_map = distance_map.astype(np.float32)
 
     return distance_map
-
-
-# ### Input layers ###
-
-# TODO add documentation
-
-def build_boundaries_layers(cyt_coord, nuc_coord, rna_coord):
-    """
-
-    Parameters
-    ----------
-    cyt_coord : np.ndarray, np.int64
-        Array of cytoplasm boundaries coordinates with shape (nb_points, 2).
-    nuc_coord : np.ndarray, np.int64
-        Array of nucleus boundaries coordinates with shape (nb_points, 2).
-    rna_coord : np.ndarray, np.int64
-        Array of mRNAs coordinates with shape (nb_points, 2) or
-        (nb_points, 3).
-
-    Returns
-    -------
-    cyt_boundaries : np.ndarray, np.float32
-        A 2-d binary tensor with shape (y, x) showing cytoplasm boundaries.
-        border.
-    nuc_boundaries : np.ndarray, np.float32
-        A 2-d binary tensor with shape (y, x) showing nucleus boundaries.
-    rna_layer : np.ndarray, np.float32
-        Binary image of mRNAs localizations with shape (y, x).
-
-    """
-    # check parameters
-    stack.check_array(cyt_coord,
-                      ndim=2,
-                      dtype=[np.int64])
-    if nuc_coord is not None:
-        stack.check_array(nuc_coord,
-                          ndim=2,
-                          dtype=[np.int64])
-    if rna_coord is not None:
-        stack.check_array(rna_coord,
-                          ndim=2,
-                          dtype=[np.int64])
-
-    # build surface binary matrices from coordinates
-    cyt_surface, nuc_surface, rna_layer, _ = stack.from_coord_to_surface(
-        cyt_coord=cyt_coord,
-        nuc_coord=nuc_coord,
-        rna_coord=rna_coord)
-
-    # from surface binary matrices to boundaries binary matrices
-    cyt_boundaries = stack.from_surface_to_boundaries(cyt_surface)
-    nuc_boundaries = stack.from_surface_to_boundaries(nuc_surface)
-
-    # cast layer in float32
-    cyt_boundaries = stack.cast_img_float32(cyt_boundaries)
-    nuc_boundaries = stack.cast_img_float32(nuc_boundaries)
-    rna_layer = stack.cast_img_float32(rna_layer)
-
-    return cyt_boundaries, nuc_boundaries, rna_layer
-
-
-def build_surface_layers(cyt_coord, nuc_coord, rna_coord):
-    """Compute plain surface layers as input for the model.
-
-    Parameters
-    ----------
-    cyt_coord : np.ndarray, np.int64
-        Array of cytoplasm boundaries coordinates with shape (nb_points, 2).
-    nuc_coord : np.ndarray, np.int64
-        Array of nucleus boundaries coordinates with shape (nb_points, 2).
-    rna_coord : np.ndarray, np.int64
-        Array of mRNAs coordinates with shape (nb_points, 2) or
-        (nb_points, 3).
-
-    Returns
-    -------
-    cyt_surface : np.ndarray, np.float32
-        A 2-d binary tensor with shape (y, x) showing cytoplasm surface.
-        border.
-    nuc_surface : np.ndarray, np.float32
-        A 2-d binary tensor with shape (y, x) showing nucleus surface.
-    rna_layer : np.ndarray, np.float32
-        Binary image of mRNAs localizations with shape (y, x).
-
-    """
-    # check parameters
-    stack.check_array(cyt_coord,
-                      ndim=2,
-                      dtype=[np.int64])
-    if nuc_coord is not None:
-        stack.check_array(nuc_coord,
-                          ndim=2,
-                          dtype=[np.int64])
-    if rna_coord is not None:
-        stack.check_array(rna_coord,
-                          ndim=2,
-                          dtype=[np.int64])
-
-    # build surface binary matrices from coordinates
-    cyt_surface, nuc_surface, rna_layer, _ = stack.from_coord_to_surface(
-        cyt_coord=cyt_coord,
-        nuc_coord=nuc_coord,
-        rna_coord=rna_coord)
-
-    # cast layer in float32
-    cyt_surface = stack.cast_img_float32(cyt_surface)
-    nuc_surface = stack.cast_img_float32(nuc_surface)
-    rna_layer = stack.cast_img_float32(rna_layer)
-
-    return cyt_surface, nuc_surface, rna_layer
-
-
-def build_distance_layers(cyt_coord, nuc_coord, rna_coord, normalized=True):
-    """Compute distance layers as input for the model.
-
-    Parameters
-    ----------
-    cyt_coord : np.ndarray, np.int64
-        Array of cytoplasm boundaries coordinates with shape (nb_points, 2).
-    nuc_coord : np.ndarray, np.int64
-        Array of nucleus boundaries coordinates with shape (nb_points, 2).
-    rna_coord : np.ndarray, np.int64
-        Array of mRNAs coordinates with shape (nb_points, 2) or
-        (nb_points, 3).
-    normalized : bool
-        Normalized the layers between 0 and 1.
-    Returns
-    -------
-    distance_cyt : np.ndarray, np.float32
-        A 2-d tensor with shape (y, x) showing distance to the cytoplasm
-        border. Normalize between 0 and 1 if 'normalized' True.
-    distance_nuc : np.ndarray, np.float32
-        A 2-d tensor with shape (y, x) showing distance to the nucleus border.
-        Normalize between 0 and 1 if 'normalized' True.
-
-    """
-    # check parameters
-    stack.check_array(cyt_coord,
-                      ndim=2,
-                      dtype=[np.int64])
-    if nuc_coord is not None:
-        stack.check_array(nuc_coord,
-                          ndim=2,
-                          dtype=[np.int64])
-    if rna_coord is not None:
-        stack.check_array(rna_coord,
-                          ndim=2,
-                          dtype=[np.int64])
-    stack.check_parameter(normalized=bool)
-
-    # build surface binary matrices from coordinates
-    cyt_surface, nuc_surface, rna_layer, _ = stack.from_coord_to_surface(
-        cyt_coord=cyt_coord,
-        nuc_coord=nuc_coord,
-        rna_coord=rna_coord)
-
-    # compute distances map for cytoplasm and nucleus
-    cyt_distance = ndi.distance_transform_edt(cyt_surface)
-    nuc_distance_ = ndi.distance_transform_edt(~nuc_surface)
-    nuc_distance = cyt_surface * nuc_distance_
-
-    if normalized:
-        # cast to np.float32 and normalize it between 0 and 1
-        cyt_distance = cyt_distance / cyt_distance.max()
-        nuc_distance = nuc_distance / nuc_distance.max()
-
-    # cast layer in float32
-    cyt_distance = stack.cast_img_float32(cyt_distance)
-    nuc_distance = stack.cast_img_float32(nuc_distance)
-    rna_layer = stack.cast_img_float32(rna_layer)
-
-    return cyt_distance, nuc_distance, rna_layer
-
-
-# ### Input image generator ###
-
-# TODO define the requirements for 'data'
-# TODO add logging
-
-class Generator:
-
-    # TODO add documentation
-    # TODO check threading.Lock()
-    # TODO add classes
-    def __init__(self, filenames, labels, input_directory, batch_size,
-                 input_shape, augmentation, with_label, nb_epoch_max=10,
-                 shuffle=True, precompute_features=False):
-
-        # make generator threadsafe
-        self.lock = threading.Lock()
-
-        # get attributes
-        self.filenames = filenames
-        self.labels = labels
-        self.input_directory = input_directory
-        self.batch_size = batch_size
-        self.input_shape = input_shape
-        self.augmentation = augmentation
-        self.with_label = with_label
-        self.nb_epoch_max = nb_epoch_max
-        self.shuffle = shuffle
-        self.precompute_features = precompute_features
-
-        # initialize generator
-        self.nb_samples = len(self.filenames)
-        self.indices = self._get_shuffled_indices()
-        self.nb_batch_per_epoch = self._get_batch_per_epoch()
-        self.i_batch = 0
-        self.i_epoch = 0
-
-        # precompute feature if necessary
-        if self.precompute_features and "cell_ID" in self.data.columns:
-            unique_cells = list(set(self.data.loc[:, "cell_ID"]))
-            self.precomputed_features = self._precompute_features(unique_cells)
-        else:
-            self.precomputed_features = None
-
-    def __len__(self):
-        if self.nb_epoch_max is None:
-            raise ValueError("This generator loops indefinitely over the "
-                             "dataset. The 'len' method can't be used.")
-        else:
-            return self.nb_samples * self.nb_epoch_max
-
-    def __bool__(self):
-        if self.nb_epoch_max is None or self.nb_epoch_max > 0:
-            return True
-        else:
-            return False
-
-    def __iter__(self):
-        return self
-
-    def __next__(self):
-        with self.lock:
-            return self._next()
-
-    def _next(self):
-        # we reach the end of an epoch
-        if self.i_batch == self.nb_batch_per_epoch:
-            self.i_epoch += 1
-
-            # the generator loop over the data indefinitely
-            if self.nb_epoch_max is None:
-                # TODO find something better
-                if self.i_epoch == 500:
-                    raise StopIteration
-                self.i_batch = 0
-                self.indices = self._get_shuffled_indices()
-                return self._next()
-
-            # we start a new epoch
-            elif (self.nb_epoch_max is not None
-                  and self.i_epoch < self.nb_epoch_max):
-                self.i_batch = 0
-                self.indices = self._get_shuffled_indices()
-                return self._next()
-
-            # we reach the maximum number of epochs
-            elif (self.nb_epoch_max is not None
-                  and self.i_epoch == self.nb_epoch_max):
-                raise StopIteration
-
-        # we build a new batch
-        else:
-            if self.with_label:
-                batch_data, batch_label = self._build_batch(self.i_batch)
-                self.i_batch += 1
-                return batch_data, batch_label
-            else:
-                batch_data = self._build_batch(self.i_batch)
-                self.i_batch += 1
-                return batch_data
-
-    def _get_shuffled_indices(self):
-        # shuffle input data and get their indices
-        input_indices_ordered = [i for i in range(self.nb_samples)]
-        if self.shuffle:
-            np.random.shuffle(input_indices_ordered)
-        return input_indices_ordered
-
-    def _get_batch_per_epoch(self):
-        # compute the number of batches to generate for the entire epoch
-        if self.nb_samples % self.batch_size == 0:
-            nb_batch = len(self.indices) // self.batch_size
-        else:
-            # the last batch can be smaller
-            nb_batch = (len(self.indices) // self.batch_size) + 1
-        return nb_batch
-
-    def _build_batch(self, i_batch):
-        # build a batch
-        start_index = i_batch * self.batch_size
-        end_index = min((i_batch + 1) * self.batch_size, self.nb_samples)
-        indices_batch = self.indices[start_index:end_index]
-
-        # return batch with label
-        if self.with_label:
-            batch_data, batch_label = build_batch(
-                data=self.data,
-                indices=indices_batch,
-                method=self.method,
-                input_shape=self.input_shape,
-                augmentation=self.augmentation,
-                with_label=self.with_label,
-                nb_classes=self.nb_classes,
-                precomputed_features=self.precomputed_features)
-
-            return batch_data, batch_label
-
-        # return batch without label
-        else:
-            batch_data = build_batch(
-                data=self.data,
-                indices=indices_batch,
-                method=self.method,
-                input_shape=self.input_shape,
-                augmentation=self.augmentation,
-                with_label=self.with_label,
-                nb_classes=self.nb_classes,
-                precomputed_features=self.precomputed_features)
-
-            return batch_data
-
-    def _precompute_features(self, unique_cells):
-        """
-
-        Parameters
-        ----------
-        unique_cells
-
-        Returns
-        -------
-
-        """
-        # TODO add documentation
-        # get a sample for each instance of cell
-        d_features = {}
-        for cell in unique_cells:
-            df_cell = self.data.loc[self.data.cell_ID == cell, :]
-            id_cell = df_cell.index[0]
-            image_ref = build_image(
-                self.data, id_cell,
-                image_shape=self.input_shape,
-                coord_refinement=True,
-                method=self.method,
-                augmentation=False)
-            d_features[cell] = (image_ref[:, :, 1], image_ref[:, :, 2])
-
-        return d_features
-
-    def reset(self):
-        # initialize generator
-        self.indices = self._get_shuffled_indices()
-        self.nb_batch_per_epoch = self._get_batch_per_epoch()
-        self.i_batch = 0
-        self.i_epoch = 0
-
-
-# TODO try to fully vectorize this step
-def build_batch(data, indices, method="normal", input_shape=(224, 224),
-                augmentation=True, with_label=False, nb_classes=9,
-                precomputed_features=None):
-    """Build a batch of data.
-
-    Parameters
-    ----------
-    data : pandas.DataFrame
-        Dataframe with the data.
-    indices : List[int]
-        List of indices to use for the batch.
-    method : str
-        Channels used in the input image.
-            - 'normal' for (rna, cyt, nuc)
-            - 'distance' for (rna, distance_cyt, distance_nuc)
-            - 'surface' for (rna, surface_cyt, surface_nuc)
-    input_shape : Tuple[int]
-        Shape of the input image.
-    augmentation : bool
-        Apply a random operator on the image.
-    with_label : bool
-        Return label of the image as well.
-    nb_classes : int
-        Number of different classes available.
-    precomputed_features : dict
-        Some datasets are simulated from a small limited set of background
-        cells (cytoplasm and nucleus). In this case, we can precompute and keep
-        in memory the related features layers in order to dramatically speed
-        up the program. this dict associate the id of the reference cells to
-        their computed features layers (cytoplasm, nucleus).
-
-    Returns
-    -------
-    batch_data : np.ndarray, np.float32
-        Tensor with shape (batch_size, x, y, 3).
-    batch_label : np.ndarray, np.int64
-        Tensor of the encoded label, with shape (batch_size,)
-
-    """
-    # initialize the batch
-    batch_size = len(indices)
-    batch_data = np.zeros((batch_size, input_shape[0], input_shape[1], 3),
-                          dtype=np.float32)
-
-    # build each input image of the batch
-    if precomputed_features is None:
-        for i in range(batch_size):
-            id_cell = indices[i]
-            image = build_image(
-                data, id_cell,
-                image_shape=input_shape,
-                coord_refinement=True,
-                method=method,
-                augmentation=augmentation)
-            batch_data[i] = image
-    else:
-        for i in range(batch_size):
-            id_cell = indices[i]
-            image = build_image_precomputed(
-                data, id_cell,
-                image_shape=input_shape,
-                precomputed_features=precomputed_features,
-                augmentation=augmentation)
-            batch_data[i] = image
-
-    # return images with one-hot labels
-    if with_label:
-        labels = np.array(data.loc[indices, "label"], dtype=np.int64)
-        batch_label = _one_hot_label(labels, nb_classes)
-
-        return batch_data, batch_label
-
-    # return images only
-    else:
-
-        return batch_data
diff --git a/bigfish/classification/preparation.py b/bigfish/classification/preparation.py
deleted file mode 100644
index 208875b1..00000000
--- a/bigfish/classification/preparation.py
+++ /dev/null
@@ -1,929 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-Functions to prepare the data before feeding a model.
-"""
-
-import os
-import threading
-
-import numpy as np
-import pandas as pd
-from scipy import ndimage as ndi
-
-from bigfish.stack.utils import get_margin_value
-from bigfish.stack.augmentation import augment_2d
-from bigfish.stack.preprocess import cast_img_float32
-from bigfish.stack.filter import mean_filter
-
-from skimage.draw import polygon_perimeter
-from sklearn.preprocessing import LabelEncoder
-
-
-# TODO define the requirements for 'data'
-# TODO add logging
-# TODO generalize the use of 'get_offset_value'
-# TODO move the script to the classification submodule
-
-# ### Split data ###
-
-def split_from_background(data, p_validation=0.2, p_test=0.2, logdir=None):
-    """Split dataset between train, validation and test, based on the
-    background volume used to simulate the cell.
-
-    Parameters
-    ----------
-    data : pandas.DataFrame
-        Dataframe with the simulated data.
-    p_validation : float
-        Proportion of the validation dataset.
-    p_test : float
-        Proportion of the test dataset.
-    logdir : str
-        Path of the log directory used to save the split indices.
-
-    Returns
-    -------
-    df_train : pandas.DataFrame
-        Dataframe with the train dataset.
-    df_validation : pandas.DataFrame
-        Dataframe with the validation dataset.
-    df_test : pandas.DataFrame
-        Dataframe with the test dataset.
-
-    """
-    # get unique background cell
-    background_id = list(set(data["cell_ID"]))
-    np.random.shuffle(background_id)
-
-    # split background cell between train, validation and test
-    nb_validation = int(len(background_id) * p_validation)
-    nb_test = int(len(background_id) * p_test)
-    validation_cell = background_id[:nb_validation]
-    test_cell = background_id[nb_validation:nb_validation+nb_test]
-    train_cell = background_id[nb_validation+nb_test:]
-
-    # split data between train, validation and test
-    data_train = data.query("cell_ID in {0}".format(str(train_cell)))
-    data_validation = data.query("cell_ID in {0}".format(str(validation_cell)))
-    data_test = data.query("cell_ID in {0}".format(str(test_cell)))
-
-    # save indices
-    if logdir is not None:
-        path = os.path.join(logdir, "indices_split.npz")
-        np.savez(path,
-                 indices_train=np.array(data_train.index),
-                 indices_validation=np.array(data_validation.index),
-                 indices_test=np.array(data_test.index))
-
-    # reset index
-    data_train.reset_index(drop=True, inplace=True)
-    data_validation.reset_index(drop=True, inplace=True)
-    data_test.reset_index(drop=True, inplace=True)
-
-    return data_train, data_validation, data_test
-
-
-# ### Filter data ###
-
-def filter_data(data, proportion_to_exclude=0.2):
-    # TODO add documentation
-
-    if (isinstance(proportion_to_exclude, float)
-            and 0 <= proportion_to_exclude <= 1):
-        p = int(proportion_to_exclude * 10)
-    elif (isinstance(proportion_to_exclude, int)
-          and 0 <= proportion_to_exclude <= 100):
-        p = proportion_to_exclude // 10
-    else:
-        raise ValueError("'proportion' must be a float between 0 and 1 or an "
-                         "integer between 0 and 100.")
-
-    # filter inNUC, nuc2D, cell3D, "cell2D" and nuc3D
-    l = ['p10', 'p20', 'p30', 'p40', 'p50', 'p60', 'p70', 'p80', 'p90', 'p100']
-    level_kept = l[:p]
-    query = "pattern_level not in {0}".format(str(level_kept))
-    data_filtered = data.query(query)
-
-    # filter foci
-    l = ['p50', 'p60', 'p70', 'p80', 'p90', 'p100', 'p110', 'p120', 'p130',
-         'p140', 'p150']
-    level_kept = l[:p]
-    query = "pattern_level not in {0} or pattern_name != 'foci'".format(
-        str(level_kept))
-    data_filtered = data_filtered.query(query)
-
-    # reset index
-    data_filtered.reset_index(drop=True, inplace=True)
-
-    return data_filtered
-
-
-# ### Balance data ###
-
-def balance_data(data, column_to_balance, verbose=0):
-    # TODO add documentation
-    # TODO make it consistent for int values
-    values = list(data.loc[:, column_to_balance].value_counts().index)
-    frequencies = list(data.loc[:, column_to_balance].value_counts())
-
-    max_frequency = max(frequencies)
-    diff_frequency = [max_frequency - frequency for frequency in frequencies]
-
-    for i, value in enumerate(values):
-        n = diff_frequency[i]
-        if verbose > 0:
-            print("add {0} new samples {1} to balance the dataset..."
-                  .format(n, value))
-        df = data.query("{0} == '{1}'".format(column_to_balance, value))
-        df = df.sample(n, replace=True, random_state=13)
-        data = pd.concat([data, df])
-    if verbose > 0:
-        print()
-
-    # reset index
-    data.reset_index(drop=True, inplace=True)
-
-    return data
-
-
-# ### Encode labels and genes ###
-
-def encode_labels(data, column_name="pattern_name", classes_to_analyse="all"):
-    """Filter classes we want to analyze and encode them from a string format
-    to a numerical one.
-
-    Parameters
-    ----------
-    data : pd.DataFrame
-        Dataframe with a feature containing the label in string format.
-    column_name : str
-        Name of the feature to use in the dataframe as label.
-    classes_to_analyse : str
-        Define the set of classes we want to keep and to encode before training
-        a model:
-        - 'experimental' to fit with the experimental data (5 classes).
-        - '2d' to analyze the 2-d classes only (7 classes).
-        - 'all' to analyze all the classes (9 classes).
-
-    Returns
-    -------
-    data : pd.DataFrame
-        Dataframe with the encoded label in an additional column 'label'. If
-        the original columns label is already named 'label', we rename both
-        columns 'label_str' and 'label_num'.
-    encoder : sklearn.preprocessing.LabelEncoder
-        Fitted encoder to encode of decode a label.
-    classes : List[str]
-        List of the classes to keep and encode.
-
-    """
-    # get label encoder
-    encoder, classes = get_label_encoder(classes_to_analyze=classes_to_analyse)
-
-    # filter rows
-    query = "{0} in {1}".format(column_name, str(classes))
-    data = data.query(query)
-
-    # encode labels
-    if column_name == "label":
-        data = data.assign(
-            label_str=data.loc[:, column_name],
-            label_num=encoder.transform(data.loc[:, column_name]))
-    else:
-        data = data.assign(
-            label=encoder.transform(data.loc[:, column_name]))
-
-    # reset index
-    data.loc[:, "original_index"] = data.index
-    data.reset_index(drop=True, inplace=True)
-
-    return data, encoder, classes
-
-
-def get_label_encoder(classes_to_analyze="all"):
-    # TODO add documentation
-    # get set of classes to analyze
-    if classes_to_analyze == "experimental":
-        classes = ["random", "foci", "cellext", "inNUC", "nuc2D"]
-    elif classes_to_analyze == "2d":
-        classes = ["random", "foci", "cellext", "inNUC", "nuc2D", "cell2D",
-                   "polarized"]
-    elif classes_to_analyze == "all":
-        classes = ["random", "foci", "cellext", "inNUC", "nuc2D", "cell2D",
-                   "polarized", "cell3D", "nuc3D"]
-    else:
-        raise ValueError("'classes_to_analyse' can only take three values: "
-                         "'experimental', '2d' or 'all'.")
-
-    # fit a label encoder
-    encoder = LabelEncoder()
-    encoder.fit(classes)
-
-    return encoder, classes
-
-
-def get_map_label(data, column_num="label", columns_str="pattern_name"):
-    # TODO add documentation
-    # TODO redo with encoder
-    label_num = list(set(data.loc[:, column_num]))
-    label_str = list(set(data.loc[:, columns_str]))
-    d = {}
-    for i, label_num_ in enumerate(label_num):
-        label_str_ = label_str[i]
-        d[label_str_] = label_num
-
-    return d
-
-
-def get_gene_encoder(genes_str):
-    # encode genes
-    encoder_gene = LabelEncoder()
-    encoder_gene.fit(genes_str)
-
-    return encoder_gene
-
-
-# ### Build images from coordinates ###
-
-def build_image(data, id_cell, image_shape=None, coord_refinement=True,
-                method="normal", augmentation=False):
-    """
-
-    Parameters
-    ----------
-    data
-    id_cell
-    image_shape
-    coord_refinement
-    method
-    augmentation
-
-    Returns
-    -------
-
-    """
-    # TODO add documentation
-    # TODO add sanity check for precomputation
-    # get coordinates
-    rna_coord, cyt_coord, nuc_coord = get_coordinates(data, id_cell,
-                                                      image_shape,
-                                                      coord_refinement)
-
-    # build matrices
-    if image_shape is None:
-        max_x = cyt_coord[:, 0].max() + get_margin_value()
-        max_y = cyt_coord[:, 1].max() + get_margin_value()
-        image_shape = (max_x, max_y)
-    rna = np.zeros(image_shape, dtype=np.float32)
-    rna[rna_coord[:, 0], rna_coord[:, 1]] = 1.0
-    cyt = np.zeros(image_shape, dtype=np.float32)
-    cyt[cyt_coord[:, 0], cyt_coord[:, 1]] = 1.0
-    nuc = np.zeros(image_shape, dtype=np.float32)
-    nuc[nuc_coord[:, 0], nuc_coord[:, 1]] = 1.0
-
-    # get features
-    if method == "normal":
-        pass
-    elif method == "surface":
-        cyt, nuc = get_surface_layers(cyt, nuc)
-    elif method == "distance":
-        cyt, nuc = get_distance_layers(cyt, nuc)
-    else:
-        raise ValueError(
-            "{0} is an invalid value for parameter 'channels': must be "
-            "'normal', 'distance' or 'surface'.".format(method))
-
-    # stack image
-    image = np.stack((rna, cyt, nuc), axis=-1)
-
-    # augment
-    if augmentation:
-        image = augment(image)
-
-    return image
-
-
-def build_image_precomputed(data, id_cell, image_shape=None,
-                            precomputed_features=None, augmentation=False):
-    """
-
-    Parameters
-    ----------
-    data
-    id_cell
-    image_shape
-    precomputed_features
-    augmentation
-
-    Returns
-    -------
-
-    """
-    # TODO add documentation
-    # TODO add sanity check for precomputation
-
-    # build rna image from coordinates data
-    rna = _build_rna(data, id_cell, image_shape)
-
-    # get precomputed features
-    id_cell = data.loc[id_cell, "cell_ID"]
-    cyt, nuc = precomputed_features[id_cell]
-
-    # build the required input image
-    image = np.stack((rna, cyt, nuc), axis=-1)
-
-    # apply augmentation
-    if augmentation:
-        image = augment(image)
-
-    return image
-
-
-def _build_rna(data, id_cell, output_shape=None):
-    # TODO add documentation
-    # TODO check if 'polygone_perimeter' changes the input shape
-    # get coordinates
-    rna_coord = data.loc[id_cell, "RNA_pos"]
-    rna_coord = np.array(rna_coord, dtype=np.int64)
-
-    # get current shape
-    cyt_coord = data.loc[id_cell, "pos_cell"]
-    cyt_coord = np.array(cyt_coord, dtype=np.int64)
-    max_x = cyt_coord[:, 0].max() + get_margin_value()
-    max_y = cyt_coord[:, 1].max() + get_margin_value()
-    input_shape = (max_x, max_y)
-
-    if output_shape is not None:
-        # compute resizing factor
-        factor = _compute_resizing_factor(input_shape, output_shape)
-
-        # resize coordinates directly
-        rna_coord = _resize_coord(rna_coord, factor)
-
-    else:
-        output_shape = input_shape
-
-    # build rna image
-    rna = np.zeros(output_shape, dtype=np.float32)
-    rna[rna_coord[:, 0], rna_coord[:, 1]] = 1.0
-
-    return rna
-
-
-def get_coordinates(data, id_cell, output_shape=None, coord_refinement=True):
-    """
-
-    Parameters
-    ----------
-    data
-    id_cell
-    output_shape
-    coord_refinement
-
-    Returns
-    -------
-
-    """
-    # TODO add documentation
-    # get coordinates
-    rna_coord = data.loc[id_cell, "RNA_pos"]
-    rna_coord = np.array(rna_coord, dtype=np.int64)
-    cyt_coord = data.loc[id_cell, "pos_cell"]
-    cyt_coord = np.array(cyt_coord, dtype=np.int64)
-    nuc_coord = data.loc[id_cell, "pos_nuc"]
-    nuc_coord = np.array(nuc_coord, dtype=np.int64)
-
-    # resize coordinates
-    if output_shape is not None:
-        max_x = cyt_coord[:, 0].max() + 5
-        max_y = cyt_coord[:, 1].max() + 5
-        input_shape = (max_x, max_y)
-        factor = _compute_resizing_factor(input_shape, output_shape)
-        rna_coord = _resize_coord(rna_coord, factor)
-        cyt_coord = _resize_coord(cyt_coord, factor[:, :2])
-        nuc_coord = _resize_coord(nuc_coord, factor[:, :2])
-
-    # complete cytoplasm and nucleus coordinates
-    if coord_refinement:
-        # TODO use util.complete_coordinates_2d
-        cyt_x, cyt_y = polygon_perimeter(cyt_coord[:, 0], cyt_coord[:, 1])
-        cyt_x = cyt_x[:, np.newaxis]
-        cyt_y = cyt_y[:, np.newaxis]
-        cyt_coord = np.concatenate((cyt_x, cyt_y), axis=-1)
-        nuc_x, nuc_y = polygon_perimeter(nuc_coord[:, 0], nuc_coord[:, 1])
-        nuc_x = nuc_x[:, np.newaxis]
-        nuc_y = nuc_y[:, np.newaxis]
-        nuc_coord = np.concatenate((nuc_x, nuc_y), axis=-1)
-
-    return rna_coord, cyt_coord, nuc_coord
-
-
-def _compute_resizing_factor(input_shape, output_shape):
-    # compute factor
-    delta_x = output_shape[0] / input_shape[0]
-    delta_y = output_shape[1] / input_shape[1]
-    factor = np.array([delta_x, delta_y, 1], dtype=np.float32)[np.newaxis, :]
-
-    return factor
-
-
-def _resize_coord(coord, factor):
-    # resize coordinates directly
-    coord = np.round(coord * factor).astype(np.int64)
-
-    return coord
-
-
-def get_distance_layers(cyt, nuc, normalized=True):
-    """Compute distance layers as input for the model.
-
-    Parameters
-    ----------
-    cyt : np.ndarray, np.float32
-        A 2-d binary image with shape (y, x).
-    nuc : np.ndarray, np.float32
-        A 2-d binary image with shape (y, x).
-    normalized : bool
-        Normalized it between 0 and 1.
-
-    Returns
-    -------
-    distance_cyt : np.ndarray, np.float32
-        A 2-d tensor with shape (y, x) showing distance to the cytoplasm
-        border. Normalize between 0 and 1 if 'normalized' True.
-    distance_nuc : np.ndarray, np.float32
-        A 2-d tensor with shape (y, x) showing distance to the nucleus border.
-        Normalize between 0 and 1 if 'normalized' True.
-
-    """
-    # TODO can return NaN
-    # compute surfaces from cytoplasm and nucleus
-    mask_cyt, mask_nuc = get_surface_layers(cyt, nuc, cast_float=False)
-
-    # compute distances from cytoplasm and nucleus
-    distance_cyt = ndi.distance_transform_edt(mask_cyt)
-    distance_nuc_ = ndi.distance_transform_edt(~mask_nuc)
-    distance_nuc = mask_cyt * distance_nuc_
-
-    if normalized:
-        # cast to np.float32 and normalize it between 0 and 1
-        distance_cyt = cast_img_float32(distance_cyt / distance_cyt.max())
-        distance_nuc = cast_img_float32(distance_nuc / distance_nuc.max())
-
-    return distance_cyt.astype(np.float32), distance_nuc.astype(np.float32)
-
-
-def get_surface_layers(cyt, nuc, cast_float=True):
-    """Compute plain surface layers as input for the model.
-
-    Sometimes the border is too fragmented to compute the surface. In this
-    case, we iteratively apply a dilatation filter (with an increasing kernel
-    size) until the boundary is properly connected the boundaries.
-
-    Parameters
-    ----------
-    cyt : np.ndarray, np.float32
-        A 2-d binary image with shape (y, x).
-    nuc : np.ndarray, np.float32
-        A 2-d binary image with shape (y, x).
-    cast_float : bool
-        Cast output in np.float32.
-
-    Returns
-    -------
-    surface_cyt : np.ndarray, np.float32
-        A 2-d binary tensor with shape (y, x) showing cytoplasm surface.
-        border.
-    surface_nuc : np.ndarray, np.float32
-        A 2-d binary tensor with shape (y, x) showing nucleus surface.
-
-        """
-    # compute surface from cytoplasm and nucleus
-    surface_cyt = ndi.binary_fill_holes(cyt)
-    surface_nuc = ndi.binary_fill_holes(nuc)
-
-    # cast to np.float32
-    if cast_float:
-        surface_cyt = cast_img_float32(surface_cyt)
-        surface_nuc = cast_img_float32(surface_nuc)
-
-    return surface_cyt, surface_nuc
-
-
-def get_label(data, id_cell):
-    """Get the label of a specific cell.
-
-    Parameters
-    ----------
-    data : pandas.DataFrame
-        Dataframe with the data.
-    id_cell : int
-        Index of the targeted cell.
-
-    Returns
-    -------
-    label : int
-        Encoded label of the cell.
-
-    """
-    # get encoded label
-    label = data.loc[id_cell, "label"]
-
-    return label
-
-
-# ### Generator ###
-
-class Generator:
-
-    # TODO add documentation
-    # TODO check threading.Lock()
-    # TODO add classes
-    def __init__(self, data, method, batch_size, input_shape, augmentation,
-                 with_label, nb_classes, nb_epoch_max=10, shuffle=True,
-                 precompute_features=False):
-        # make generator threadsafe
-        self.lock = threading.Lock()
-
-        # get attributes
-        self.data = data
-        self.method = method
-        self.batch_size = batch_size
-        self.input_shape = input_shape
-        self.augmentation = augmentation
-        self.with_label = with_label
-        self.nb_classes = nb_classes
-        self.nb_epoch_max = nb_epoch_max
-        self.shuffle = shuffle
-        self.precompute_features = precompute_features
-
-        # initialize generator
-        self.nb_samples = self.data.shape[0]
-        self.indices = self._get_shuffled_indices()
-        self.nb_batch_per_epoch = self._get_batch_per_epoch()
-        self.i_batch = 0
-        self.i_epoch = 0
-
-        # precompute feature if necessary
-        if self.precompute_features and "cell_ID" in self.data.columns:
-            unique_cells = list(set(self.data.loc[:, "cell_ID"]))
-            self.precomputed_features = self._precompute_features(unique_cells)
-        else:
-            self.precomputed_features = None
-
-    def __len__(self):
-        if self.nb_epoch_max is None:
-            raise ValueError("This generator loops indefinitely over the "
-                             "data. The 'len' method can't be used.")
-        else:
-            return self.nb_samples * self.nb_epoch_max
-
-    def __bool__(self):
-        if self.nb_epoch_max is None or self.nb_epoch_max > 0:
-            return True
-        else:
-            return False
-
-    def __iter__(self):
-        return self
-
-    def __next__(self):
-        with self.lock:
-            return self._next()
-
-    def _next(self):
-        # we reach the end of an epoch
-        if self.i_batch == self.nb_batch_per_epoch:
-            self.i_epoch += 1
-
-            # the generator loop over the data indefinitely
-            if self.nb_epoch_max is None:
-                # TODO find something better
-                if self.i_epoch == 500:
-                    raise StopIteration
-                self.i_batch = 0
-                self.indices = self._get_shuffled_indices()
-                return self._next()
-
-            # we start a new epoch
-            elif (self.nb_epoch_max is not None
-                  and self.i_epoch < self.nb_epoch_max):
-                self.i_batch = 0
-                self.indices = self._get_shuffled_indices()
-                return self._next()
-
-            # we reach the maximum number of epochs
-            elif (self.nb_epoch_max is not None
-                  and self.i_epoch == self.nb_epoch_max):
-                raise StopIteration
-
-        # we build a new batch
-        else:
-            if self.with_label:
-                batch_data, batch_label = self._build_batch(self.i_batch)
-                self.i_batch += 1
-                return batch_data, batch_label
-            else:
-                batch_data = self._build_batch(self.i_batch)
-                self.i_batch += 1
-                return batch_data
-
-    def _get_shuffled_indices(self):
-        # shuffle input data and get their indices
-        input_indices_ordered = list(self.data.index)
-        if self.shuffle:
-            np.random.shuffle(input_indices_ordered)
-        return input_indices_ordered
-
-    def _get_batch_per_epoch(self):
-        # compute the number of batches to generate for the entire epoch
-        if self.nb_samples % self.batch_size == 0:
-            nb_batch = len(self.indices) // self.batch_size
-        else:
-            # the last batch can be smaller
-            nb_batch = (len(self.indices) // self.batch_size) + 1
-        return nb_batch
-
-    def _build_batch(self, i_batch):
-        # build a batch
-        start_index = i_batch * self.batch_size
-        end_index = min((i_batch + 1) * self.batch_size, self.nb_samples)
-        indices_batch = self.indices[start_index:end_index]
-
-        # return batch with label
-        if self.with_label:
-            batch_data, batch_label = build_batch(
-                data=self.data,
-                indices=indices_batch,
-                method=self.method,
-                input_shape=self.input_shape,
-                augmentation=self.augmentation,
-                with_label=self.with_label,
-                nb_classes=self.nb_classes,
-                precomputed_features=self.precomputed_features)
-
-            return batch_data, batch_label
-
-        # return batch without label
-        else:
-            batch_data = build_batch(
-                data=self.data,
-                indices=indices_batch,
-                method=self.method,
-                input_shape=self.input_shape,
-                augmentation=self.augmentation,
-                with_label=self.with_label,
-                nb_classes=self.nb_classes,
-                precomputed_features=self.precomputed_features)
-
-            return batch_data
-
-    def _precompute_features(self, unique_cells):
-        """
-
-        Parameters
-        ----------
-        unique_cells
-
-        Returns
-        -------
-
-        """
-        # TODO add documentation
-        # get a sample for each instance of cell
-        d_features = {}
-        for cell in unique_cells:
-            df_cell = self.data.loc[self.data.cell_ID == cell, :]
-            id_cell = df_cell.index[0]
-            image_ref = build_image(
-                self.data, id_cell,
-                image_shape=self.input_shape,
-                coord_refinement=True,
-                method=self.method,
-                augmentation=False)
-            d_features[cell] = (image_ref[:, :, 1], image_ref[:, :, 2])
-
-        return d_features
-
-    def reset(self):
-        # initialize generator
-        self.indices = self._get_shuffled_indices()
-        self.nb_batch_per_epoch = self._get_batch_per_epoch()
-        self.i_batch = 0
-        self.i_epoch = 0
-
-
-# TODO try to fully vectorize this step
-def build_batch(data, indices, method="normal", input_shape=(224, 224),
-                augmentation=True, with_label=False, nb_classes=9,
-                precomputed_features=None):
-    """Build a batch of data.
-
-    Parameters
-    ----------
-    data : pandas.DataFrame
-        Dataframe with the data.
-    indices : List[int]
-        List of indices to use for the batch.
-    method : str
-        Channels used in the input image.
-            - 'normal' for (rna, cyt, nuc)
-            - 'distance' for (rna, distance_cyt, distance_nuc)
-            - 'surface' for (rna, surface_cyt, surface_nuc)
-    input_shape : Tuple[int]
-        Shape of the input image.
-    augmentation : bool
-        Apply a random operator on the image.
-    with_label : bool
-        Return label of the image as well.
-    nb_classes : int
-        Number of different classes available.
-    precomputed_features : dict
-        Some datasets are simulated from a small limited set of background
-        cells (cytoplasm and nucleus). In this case, we can precompute and keep
-        in memory the related features layers in order to dramatically speed
-        up the program. this dict associate the id of the reference cells to
-        their computed features layers (cytoplasm, nucleus).
-
-    Returns
-    -------
-    batch_data : np.ndarray, np.float32
-        Tensor with shape (batch_size, x, y, 3).
-    batch_label : np.ndarray, np.int64
-        Tensor of the encoded label, with shape (batch_size,)
-
-    """
-    # initialize the batch
-    batch_size = len(indices)
-    batch_data = np.zeros((batch_size, input_shape[0], input_shape[1], 3),
-                          dtype=np.float32)
-
-    # build each input image of the batch
-    if precomputed_features is None:
-        for i in range(batch_size):
-            id_cell = indices[i]
-            image = build_image(
-                data, id_cell,
-                image_shape=input_shape,
-                coord_refinement=True,
-                method=method,
-                augmentation=augmentation)
-            batch_data[i] = image
-    else:
-        for i in range(batch_size):
-            id_cell = indices[i]
-            image = build_image_precomputed(
-                data, id_cell,
-                image_shape=input_shape,
-                precomputed_features=precomputed_features,
-                augmentation=augmentation)
-            batch_data[i] = image
-
-    # return images with one-hot labels
-    if with_label:
-        labels = np.array(data.loc[indices, "label"], dtype=np.int64)
-        batch_label = _one_hot_label(labels, nb_classes)
-
-        return batch_data, batch_label
-
-    # return images only
-    else:
-
-        return batch_data
-
-
-def _one_hot_label(labels, nb_classes):
-    """Binarize labels in a one-vs-all fashion.
-
-    Parameters
-    ----------
-    labels : np.ndarray, np.int64
-        Vector of labels with shape (nb_sample,).
-    nb_classes : int
-        Number of different classes available.
-
-    Returns
-    -------
-    label_one_hot : np.ndarray, np.float32
-        One-hot label (binary) with shape (nb_samples, nb_classes).
-
-    """
-    # binarize labels
-    label_one_hot = np.eye(nb_classes, dtype=np.float32)[labels]
-
-    return label_one_hot
-
-
-# ### Experimental data ###
-
-def format_experimental_data(data, label_encoder=None):
-    # TODO add documentation
-    # initialize the formatted dataset
-    data_formatted = data.copy(deep=True)
-
-    # format coordinates
-    data_formatted.loc[:, 'pos_cell'] = data_formatted.apply(
-        lambda row: _decompose_experimental_coordinate(row["pos"].T)[0],
-        axis=1)
-    data_formatted.loc[:, 'pos_nuc'] = data_formatted.apply(
-        lambda row: _decompose_experimental_coordinate(row["pos"].T)[1],
-        axis=1)
-    data_formatted.loc[:, 'RNA_pos'] = data_formatted.apply(
-        lambda row: _decompose_experimental_coordinate(row["pos"].T)[2],
-        axis=1)
-
-    # format cell indices
-    data_formatted.loc[:, 'cell_ID'] = data_formatted.index
-
-    # format RNA count
-    data_formatted.loc[:, 'nb_rna'] = data_formatted.apply(
-        lambda row: len(row["RNA_pos"]),
-        axis=1)
-
-    # format label
-    if label_encoder is not None:
-        pattern_level = [None] * data_formatted.shape[0]
-        data_formatted.loc[:, 'pattern_level'] = pattern_level
-        data_formatted.loc[:, 'pattern_name'] = data_formatted.apply(
-            lambda row: _label_experimental_num_to_str_(row["labels"]),
-            axis=1)
-        data_formatted.loc[:, 'label'] = data_formatted.apply(
-            lambda row: label_encoder.transform([row["pattern_name"]])[0],
-            axis=1)
-
-    # remove useless columns
-    if label_encoder is not None:
-        features_to_keep = ['gene', 'pos_nuc', 'pos_cell', 'RNA_pos', 'cell_ID',
-                            'nb_rna', 'pattern_level', 'pattern_name', 'label']
-    else:
-        features_to_keep = ['gene', 'pos_nuc', 'pos_cell', 'RNA_pos',
-                            'cell_ID', 'nb_rna']
-    data_formatted = data_formatted.loc[:, features_to_keep]
-
-    return data_formatted
-
-
-def _decompose_experimental_coordinate(positions):
-    # TODO add documentation
-    # get coordinate for each element of the cell
-    nuc_coord = positions[positions[:, 2] == 0]
-    nuc_coord = nuc_coord[:, :2].astype(np.int64)
-    cyt_coord = positions[positions[:, 2] == 1]
-    cyt_coord = cyt_coord[:, :2].astype(np.int64)
-    rna_coord = positions[positions[:, 2] == 2]
-    rna_coord = rna_coord.astype(np.int64)
-    rna_coord[:, 2] = np.zeros((rna_coord.shape[0],), dtype=np.int64)
-
-    return cyt_coord, nuc_coord, rna_coord
-
-
-def _label_experimental_num_to_str_(label_num):
-    # TODO add documentation
-    if label_num == 1:
-        label_str = "foci"
-    elif label_num == 2:
-        label_str = "cellext"
-    elif label_num == 3:
-        label_str = "inNUC"
-    elif label_num == 4:
-        label_str = "nuc2D"
-    elif label_num == 5:
-        label_str = "random"
-    else:
-        raise ValueError("Label value should be comprised between 1 and 5.")
-
-    return label_str
-
-
-def remove_transcription_site_bis(data, threshold):
-    # TODO add documentation
-    # TODO vectorize it
-    data_corrected = data.copy(deep=True)
-    for index, row in data_corrected.iterrows():
-        id_cell = row['cell_ID']
-        image = build_image(data, id_cell,
-                            coord_refinement=True,
-                            method="surface")
-        rna, cyt, nuc = image[:, :, 0], image[:, :, 1], image[:, :, 2]
-
-        rna_in = np.copy(rna)
-        rna_in[nuc == 0] = 0
-        rna_out = np.copy(rna)
-        rna_out[nuc > 0] = 0
-        rna_in = 255 * rna_in.astype(np.uint8)
-        density_img = mean_filter(rna_in, kernel_shape="disk", kernel_size=4)
-        density_img = cast_img_float32(density_img)
-        transcription_site = density_img > threshold
-        rna_in[transcription_site] = 0
-
-        rna = rna_in + rna_out
-
-        rna_pos = np.nonzero(rna)
-        rna_pos = np.column_stack(rna_pos).astype(np.int64)
-        rna_pos = np.concatenate(
-            [rna_pos, np.zeros((rna_pos.shape[0], 1), dtype=np.int64)],
-            axis=1)
-        data_corrected.at[index, 'RNA_pos'] = rna_pos
-
-    return data_corrected
diff --git a/bigfish/deep_learning/__init__.py b/bigfish/deep_learning/__init__.py
index eaca731f..75f8d3e7 100644
--- a/bigfish/deep_learning/__init__.py
+++ b/bigfish/deep_learning/__init__.py
@@ -1,12 +1,68 @@
 # -*- coding: utf-8 -*-
 
 """
-The bigfish.deep_learning module includes deep learning models and routines.
+The bigfish.deep_learning subpackage includes deep learning models and their
+routines.
 """
 
-from .squeezenet import SqueezeNet0, SqueezeNet_qbi
+import warnings
 
+# check tensorflow package
+try:
+    import tensorflow
+    tf_version = tensorflow.__version__
+    if tf_version != "2.3.0":
+        warnings.warn("Available tensorflow version ({0}) differs from "
+                      "requirements (2.3.0).".format(tf_version),
+                      category=ImportWarning)
+except ImportError:
+    raise ImportError(
+        "Tensorflow package (v2.3.0) is missing. You can install it by "
+        "running the command 'pip install tensorflow==2.3.0' in a bash shell.")
 
-_squeezenet = ["SqueezeNet0", "SqueezeNet_qbi"]
+# check tensorflow-addons package
+try:
+    import tensorflow_addons
+    tfa_version = tensorflow_addons.__version__
+    if tfa_version != "0.12.1":
+        warnings.warn("Available tensorflow-addons version ({0}) differs from "
+                      "requirements (0.12.1).".format(tfa_version),
+                      category=ImportWarning)
+except ImportError:
+    raise ImportError(
+        "Tensorflow addons package (v0.12.1) is missing. You can install it by"
+        "running the command 'pip install tensorflow-addons==0.12.1' in a "
+        "bash shell.")
 
-__all__ = _squeezenet
+from .utils_models import SameConv
+from .utils_models import UpConv
+from .utils_models import DownBlock
+from .utils_models import UpBlock
+from .utils_models import Encoder
+from .utils_models import Decoder
+from .utils_models import EncoderDecoder
+
+from .models_segmentation import load_pretrained_model
+from .models_segmentation import check_pretrained_weights
+from .models_segmentation import build_compile_3_classes_model
+from .models_segmentation import build_compile_distance_model
+from .models_segmentation import build_compile_double_distance_model
+
+
+_utils_models = [
+    "SameConv",
+    "DownBlock",
+    "UpBlock",
+    "Encoder",
+    "Decoder",
+    "EncoderDecoder"]
+
+_models_segmentation = [
+    "load_pretrained_model",
+    "check_pretrained_weights",
+    "build_compile_3_classes_model",
+    "build_compile_distance_model",
+    "build_compile_double_distance_model"]
+
+
+__all__ = (_utils_models, _models_segmentation)
diff --git a/bigfish/deep_learning/base.py b/bigfish/deep_learning/base.py
deleted file mode 100644
index 8845f48e..00000000
--- a/bigfish/deep_learning/base.py
+++ /dev/null
@@ -1,101 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-General classes and methods to use the models.
-"""
-
-from abc import ABCMeta, abstractmethod
-
-from tensorflow.python.keras.optimizers import (Adam, Adadelta, Adagrad,
-                                                Adamax, SGD)
-
-
-# ### General models ###
-
-class BaseModel(object, metaclass=ABCMeta):
-
-    def __init__(self):
-        pass
-
-    @abstractmethod
-    def fit(self, train_data, train_label, validation_data, validation_label,
-            batch_size, nb_epochs):
-        pass
-
-    @abstractmethod
-    def fit_generator(self, train_generator, validation_generator, nb_epochs,
-                      nb_workers=1, multiprocessing=False):
-        pass
-
-    @abstractmethod
-    def predict(self, data, return_probability=False):
-        pass
-
-    @abstractmethod
-    def predict_generator(self, generator, return_probability=False,
-                          nb_workers=1, multiprocessing=False):
-        pass
-
-    @abstractmethod
-    def predict_probability(self, data):
-        pass
-
-    @abstractmethod
-    def predict_probability_generator(self, generator,
-                                      nb_workers=1, multiprocessing=False):
-        pass
-
-    @abstractmethod
-    def evaluate(self, data, label):
-        pass
-
-    @abstractmethod
-    def evaluate_generator(self, generator, nb_workers=1,
-                           multiprocessing=False):
-        pass
-
-
-# ### optimizer ###
-
-def get_optimizer(optimizer_name="adam", **kwargs):
-    """Instantiate the optimizer.
-
-    Parameters
-    ----------
-    optimizer_name : str
-        Name of the optimizer to use.
-
-    Returns
-    -------
-    optimizer : tf.keras.optimizers
-        Optimizer instance used in the model.
-
-    """
-    # TODO use tensorflow optimizer
-    if optimizer_name == "adam":
-        optimizer = Adam(**kwargs)
-    elif optimizer_name == "adadelta":
-        optimizer = Adadelta(**kwargs)
-    elif optimizer_name == "adagrad":
-        optimizer = Adagrad(**kwargs)
-    elif optimizer_name == "adamax":
-        optimizer = Adamax(**kwargs)
-    elif optimizer_name == "sgd":
-        optimizer = SGD(**kwargs)
-    else:
-        raise ValueError("Instead of {0}, optimizer must be chosen among "
-                         "['adam', 'adadelta', 'adagrad', adamax', sgd']."
-                         .format(optimizer_name))
-
-    return optimizer
-
-
-
-
-#print(globals())
-#print()
-#print(globals()["BaseModel"])
-#print()
-#print(locals())
-#print()
-#print(BaseModel.__subclasses__())
diff --git a/bigfish/deep_learning/models_segmentation.py b/bigfish/deep_learning/models_segmentation.py
new file mode 100644
index 00000000..f1b76423
--- /dev/null
+++ b/bigfish/deep_learning/models_segmentation.py
@@ -0,0 +1,489 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Segmentation models.
+"""
+
+import os
+import warnings
+from zipfile import ZipFile
+
+import bigfish.stack as stack
+
+import tensorflow as tf
+
+from tensorflow.python.keras.layers import Input
+from tensorflow.python.keras.layers import Softmax
+from tensorflow.python.keras.layers import Concatenate
+from tensorflow.python.keras.engine.training import Model
+
+from .utils_models import EncoderDecoder
+from .utils_models import SameConv
+
+
+# ### Pre-trained models ###
+
+def load_pretrained_model(channel, model_name):
+    """Build and compile a model, then load its pretrained weights.
+
+    Parameters
+    ----------
+    channel : str
+        Input channel for the model ('nuc', 'cell' or 'double').
+    model_name : str
+        Name of the model used ('3-classes' or 'double_distance_edge).
+
+    Returns
+    -------
+    model : tensorflow.keras.model object
+        Pretrained Unet model.
+
+    """
+    # TODO fix warning partial restoration with distance model
+
+    # check parameters
+    stack.check_parameter(channel=str,
+                          model_name=str)
+
+    # unet 3-classes for nucleus segmentation
+    if model_name == "3_classes" and channel == "nuc":
+        model = build_compile_3_classes_model()
+
+    # unet distance map to edge for both nucleus and cell segmentation
+    elif model_name == "distance_edge" and channel == "double":
+        model = build_compile_double_distance_model()
+
+    else:
+        raise ValueError("Model name and channel to segment are not "
+                         "consistent: {0} - {1}.".format(channel, model_name))
+
+    # load weights
+    path_pretrained_directory = check_pretrained_weights(channel, model_name)
+    path_checkpoint = os.path.join(
+        path_pretrained_directory, "checkpoint")
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        model.load_weights(path_checkpoint)
+
+    return model
+
+
+def check_pretrained_weights(channel, model_name):
+    """Check pretrained weights exist and download them if necessary.
+
+    Parameters
+    ----------
+    channel : str
+        Input channel for the model ('nuc', 'cell' or 'double').
+    model_name : str
+        Name of the model used ('3-classes' or 'distance_edge').
+
+    Returns
+    -------
+    directory_checkpoint : str
+        Path of the checkpoint directory.
+
+    """
+    # check parameters
+    stack.check_parameter(channel=str,
+                          model_name=str)
+
+    # get path checkpoint
+    path_weights_directory = _get_weights_directory()
+    pretrained_directory = "_".join([channel, model_name])
+    path_directory = os.path.join(path_weights_directory, pretrained_directory)
+    download = False
+
+    # get url and hash
+    if channel == "nuc" and model_name == "3_classes":
+        url_zip_file = "https://github.com/fish-quant/big-fish-examples/releases/download/weights_1/nuc_3_classes.zip"
+        hash_checkpoint = "02988027faf3f16b4088ee83c2ade14098e8ffb325c23a576cc639dae48aa936"
+        hash_data = "284d6b8cadb3eddd691f1407bfdd1a7e6fa085c57a2feac07446e84aa3b1baf8"
+        hash_index = "c924f0f2be179340dc5c75e21833fd831d5a4efdfb48382edbbcff748a162bc4"
+        hash_log = "b6d08b9fbacd3430d89eef2cd5c3246eb8c93e7b970ba17a76c8a9d72f58c8e7"
+    elif channel == "double" and model_name == "distance_edge":
+        url_zip_file = "https://github.com/fish-quant/big-fish-examples/releases/download/weights_0/double_distance_edge.zip"
+        hash_checkpoint = "02988027faf3f16b4088ee83c2ade14098e8ffb325c23a576cc639dae48aa936"
+        hash_data = "528aecbc6418df8d0d73fb23b3518ef779ae5135e15ca3e2e67518726f998d4d"
+        hash_index = "cbebdd86868e46507733fccca589f940a438f4fde6f5d935e61f4112047db63e"
+        hash_log = "63bc7e629d464bbf6cd3afd466f4d89a3be10edb2633de7ab825384a61326a09"
+    else:
+        raise ValueError("Model name and channel to segment are not "
+                         "consistent: {0} - {1}.".format(channel, model_name))
+
+    # case where pretrained directory exists
+    if os.path.isdir(path_directory):
+
+        # paths
+        path_checkpoint = os.path.join(
+            path_directory, "checkpoint")
+        path_data = os.path.join(
+            path_directory, "checkpoint.data-00000-of-00001")
+        path_index = os.path.join(
+            path_directory, "checkpoint.index")
+        path_log = os.path.join(
+            path_directory, "log")
+
+        # checkpoint available and not corrupted
+        if os.path.exists(path_checkpoint):
+            try:
+                stack.check_hash(path_checkpoint, hash_checkpoint)
+            except IOError:
+                print("{0} seems corrupted.".format(path_checkpoint))
+                download = True
+        else:
+            download = True
+
+        # data available and not corrupted
+        if os.path.exists(path_data):
+            try:
+                stack.check_hash(path_data, hash_data)
+            except IOError:
+                print("{0} seems corrupted.".format(path_data))
+                download = True
+        else:
+            download = True
+
+        # index available and not corrupted
+        if os.path.exists(path_index):
+            try:
+                stack.check_hash(path_index, hash_index)
+            except IOError:
+                print("{0} seems corrupted.".format(path_index))
+                download = True
+        else:
+            download = True
+
+        # log available and not corrupted
+        if os.path.exists(path_log):
+            try:
+                stack.check_hash(path_log, hash_log)
+            except IOError:
+                print("{0} seems corrupted.".format(path_log))
+                download = True
+        else:
+            download = True
+
+    # case where pretrained directory does not exist
+    else:
+        download = True
+
+    # download checkpoint files
+    if download:
+        print("downloading model weights...")
+
+        # download zipfile
+        stack.load_and_save_url(
+            remote_url=url_zip_file,
+            directory=path_weights_directory)
+
+        # unzip
+        path_zipfile = os.path.join(
+            path_weights_directory, "{0}.zip".format(pretrained_directory))
+        with ZipFile(path_zipfile, 'r') as zip:
+            zip.extract(
+                member="{0}/checkpoint".format(pretrained_directory),
+                path=path_weights_directory)
+            zip.extract(
+                member="{0}/checkpoint.data-00000-of-00001"
+                    .format(pretrained_directory),
+                path=path_weights_directory)
+            zip.extract(
+                member="{0}/checkpoint.index".format(pretrained_directory),
+                path=path_weights_directory)
+            zip.extract(
+                member="{0}/log".format(pretrained_directory),
+                path=path_weights_directory)
+
+        # check files consistency
+        path = os.path.join(
+            path_weights_directory,
+            "{0}/checkpoint".format(pretrained_directory))
+        stack.check_hash(path, hash_checkpoint)
+        path = os.path.join(
+            path_weights_directory,
+            "{0}/checkpoint.data-00000-of-00001".format(pretrained_directory))
+        stack.check_hash(path, hash_data)
+        path = os.path.join(
+            path_weights_directory,
+            "{0}/checkpoint.index".format(pretrained_directory))
+        stack.check_hash(path, hash_index)
+        path = os.path.join(
+            path_weights_directory,
+            "{0}/log".format(pretrained_directory))
+        stack.check_hash(path, hash_log)
+
+        # remove zipfile
+        os.remove(path_zipfile)
+
+    return path_directory
+
+
+def _get_weights_directory():
+    path = os.path.realpath(__file__)
+    path = os.path.dirname(path)
+    path = os.path.dirname(path)
+    path = os.path.dirname(path)
+    path = os.path.join(path, "weights")
+
+    return path
+
+
+# ### Models 3-classes ###
+
+def build_compile_3_classes_model():
+    """Build and compile a Unet model to predict 3 classes from nucleus or
+    cell images: background, edge and foreground.
+
+    Returns
+    -------
+    model_3_classes : tensorflow.keras.model object
+        Compiled Unet model.
+
+    """
+    # define inputs
+    inputs_image = Input(
+        shape=(None, None, 1), dtype="float32", name="image")
+
+    # define model
+    outputs = _get_3_classes_model(inputs_image)
+    model_3_classes = Model(
+        inputs_image,
+        outputs,
+        name="3ClassesModel")
+
+    # losses
+    loss = tf.keras.losses.SparseCategoricalCrossentropy()
+
+    # metrics
+    accuracy = tf.metrics.SparseCategoricalAccuracy(name="accuracy")
+
+    # compile model
+    model_3_classes.compile(
+        optimizer='adam',
+        loss=loss,
+        metrics=accuracy)
+
+    return model_3_classes
+
+
+def _get_3_classes_model(inputs):
+    """Build Unet architecture that return a 3-classes prediction.
+
+    Parameters
+    ----------
+    inputs : tensorflow.keras.Input object
+        Input layer with shape (B, H, W, 1) or (H, W, 1).
+
+    Returns
+    -------
+    output : tensorflow.keras.layers object
+        Output layer (softmax) with shape (B, H, W, 3) or (H, W, 3).
+
+    """
+    # compute feature map
+    features_core = EncoderDecoder(
+        name="encoder_decoder")(inputs)  # (B, H, W, 32)
+
+    # compute 3-classes output
+    features_3_classes = SameConv(
+        filters=3,
+        kernel_size=(1, 1),
+        activation="linear",
+        name="final_conv")(features_core)  # (B, H, W, 3)
+    output = Softmax(
+        axis=-1,
+        name="label_3")(features_3_classes)  # (B, H, W, 3)
+
+    return output
+
+
+# ### Models distance map ###
+
+def build_compile_distance_model():
+    """Build and compile a Unet model to predict foreground and a distance map
+    from nucleus or cell images.
+
+    Returns
+    -------
+    model_distance : Tensorflow model
+        Compiled Unet model.
+
+    """
+    # define inputs
+    inputs_image = Input(
+        shape=(None, None, 1), dtype="float32", name="image")
+
+    # define model
+    output_surface, output_distance = _get_distance_model(inputs_image)
+    outputs = [output_surface, output_distance]
+    model_distance = Model(
+        inputs_image,
+        outputs,
+        name="DistanceModel")
+
+    # losses
+    loss_surface = tf.keras.losses.BinaryCrossentropy()
+    loss_distance = tf.keras.losses.MeanAbsoluteError()
+    losses = [[loss_surface], [loss_distance]]
+    losses_weight = [[1.0], [1.0]]
+
+    # metrics
+    metric_surface = tf.metrics.BinaryAccuracy(name="accuracy")
+    metric_distance = tf.metrics.MeanAbsoluteError(name="mae")
+    metrics = [[metric_surface], [metric_distance]]
+
+    # compile model
+    model_distance.compile(
+        optimizer='adam',
+        loss=losses,
+        loss_weights=losses_weight,
+        metrics=metrics)
+
+    return model_distance
+
+
+def _get_distance_model(inputs):
+    """Build Unet architecture that return a distance map.
+
+    Parameters
+    ----------
+    inputs : tensorflow.keras.Input object
+        Input layer with shape (B, H, W, 1) or (H, W, 1).
+
+    Returns
+    -------
+    output_surface : tensorflow.keras.layers object
+        Output layer for the foreground/background prediction with shape
+        (B, H, W, 1) or (H, W, 1).
+    output_distance : tensorflow.keras.layers object
+        Output layer for the distance map with shape (B, H, W, 1) or (H, W, 1).
+
+    """
+    # compute feature map
+    features_core = EncoderDecoder(
+        name="encoder_decoder")(inputs)  # (B, H, W, 32)
+
+    # compute surface output
+    output_surface = SameConv(
+        filters=1,
+        kernel_size=(1, 1),
+        activation="sigmoid",
+        name="label_2")(features_core)  # (B, H, W, 1)
+
+    # compute distance output
+    output_distance = SameConv(
+        filters=1,
+        kernel_size=(1, 1),
+        activation="relu",
+        name="label_distance")(features_core)  # (B, H, W, 1)
+
+    return output_surface, output_distance
+
+
+def build_compile_double_distance_model():
+    """Build and compile a Unet model to predict foreground and a distance map
+    from nucleus and cell images.
+
+    This model version takes two images as input (for nucleus and cell).
+
+    Returns
+    -------
+    model_distance : Tensorflow model
+        Compiled Unet model.
+
+    """
+    # define inputs
+    inputs_nuc = Input(
+        shape=(None, None, 1), dtype="float32", name="nuc")
+    inputs_cell = Input(
+        shape=(None, None, 1), dtype="float32", name="cell")
+    inputs = [inputs_nuc, inputs_cell]
+
+    # define model
+    (output_distance_nuc, output_surface_cell,
+     output_distance_cell) = _get_double_distance_model(inputs)
+    outputs = [output_distance_nuc, output_surface_cell, output_distance_cell]
+    model_distance = Model(
+        inputs,
+        outputs,
+        name="DoubleDistanceModel")
+
+    # losses
+    loss_distance_nuc = tf.keras.losses.MeanAbsoluteError()
+    loss_surface_cell = tf.keras.losses.BinaryCrossentropy()
+    loss_distance_cell = tf.keras.losses.MeanAbsoluteError()
+    losses = [[loss_distance_nuc],
+              [loss_surface_cell], [loss_distance_cell]]
+    losses_weight = [[1.0], [1.0], [1.0]]
+
+    # metrics
+    metric_distance_nuc = tf.metrics.MeanAbsoluteError(name="mae")
+    metric_surface_cell = tf.metrics.BinaryAccuracy(name="accuracy")
+    metric_distance_cell = tf.metrics.MeanAbsoluteError(name="mae")
+    metrics = [[metric_distance_nuc],
+               [metric_surface_cell], [metric_distance_cell]]
+
+    # compile model
+    model_distance.compile(
+        optimizer='adam',
+        loss=losses,
+        loss_weights=losses_weight,
+        metrics=metrics)
+
+    return model_distance
+
+
+def _get_double_distance_model(inputs):
+    """Build Unet architecture that return nucleus and cell distance maps, and
+    a cell surface prediction.
+
+    Parameters
+    ----------
+    inputs : List[tensorflow.keras.Input object]
+        List of two input layer with shape (B, H, W, 1) or (H, W, 1).
+
+    Returns
+    -------
+    output_distance_nuc : tensorflow.keras.layers object
+        Output layer for the nucleus distance map with shape (B, H, W, 1) or
+        (H, W, 1).
+    output_surface_cell : tensorflow.keras.layers object
+        Output layer for the cell foreground/background prediction with shape
+        (B, H, W, 1) or (H, W, 1).
+    output_distance_cell : tensorflow.keras.layers object
+        Output layer for the cell distance map with shape (B, H, W, 1) or
+        (H, W, 1).
+
+    """
+    # compute feature map
+    inputs_nuc, inputs_cell = inputs
+    inputs = Concatenate(
+        axis=-1)([inputs_nuc, inputs_cell])  # (B, H, W, 2)
+    features_core = EncoderDecoder(
+        name="encoder_decoder")(inputs)  # (B, H, W, 32)
+
+    # compute distance output nucleus
+    output_distance_nuc = SameConv(
+        filters=1,
+        kernel_size=(1, 1),
+        activation="relu",
+        name="label_distance_nuc")(features_core)  # (B, H, W, 1)
+
+    # compute surface output cell
+    output_surface_cell = SameConv(
+        filters=1,
+        kernel_size=(1, 1),
+        activation="sigmoid",
+        name="label_2_cell")(features_core)  # (B, H, W, 1)
+
+    # compute distance output cell
+    output_distance_cell = SameConv(
+        filters=1,
+        kernel_size=(1, 1),
+        activation="relu",
+        name="label_distance_cell")(features_core)  # (B, H, W, 1)
+
+    return output_distance_nuc, output_surface_cell, output_distance_cell
diff --git a/bigfish/deep_learning/squeezenet.py b/bigfish/deep_learning/squeezenet.py
deleted file mode 100644
index ea6b179e..00000000
--- a/bigfish/deep_learning/squeezenet.py
+++ /dev/null
@@ -1,1097 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-Models based on SqueezeNet.
-
-Paper: "SqueezeNet: AlexNet-level accuracy with 50x fewer parameters
-        and <0.5MB model size"
-Authors: Iandola, Forrest N
-         Han, Song
-         Moskewicz, Matthew W
-         Ashraf, Khalid
-         Dally, William J
-         Keutzer, Kurt
-Year: 2016
-Version: 1.0 and 1.1 (see github https://github.com/DeepScale/SqueezeNet)
-"""
-
-import os
-
-import tensorflow as tf
-import numpy as np
-
-from bigfish.deep_learning.base import BaseModel, get_optimizer
-
-from tensorflow.python.keras.backend import function, learning_phase
-from tensorflow.python.keras.models import Model
-from tensorflow.python.keras.callbacks import ModelCheckpoint, EarlyStopping
-from tensorflow.python.keras.layers import (Conv2D, Concatenate, MaxPooling2D,
-                                            Dropout, GlobalAveragePooling2D,
-                                            Add, Input, Activation,
-                                            ZeroPadding2D, BatchNormalization,
-                                            Dense)
-
-
-# TODO add logging routines
-# TODO add cache routines
-# TODO manage multiprocessing
-# TODO improve logging
-# TODO use last version of the model
-# ### 2D models ###
-
-class SqueezeNet0(BaseModel):
-    # TODO add documentation
-
-    def __init__(self, nb_classes, bypass=False, optimizer="adam",
-                 logdir=None):
-        # get model's attributes
-        super().__init__()
-        self.nb_classes = nb_classes
-        self.logdir = logdir
-
-        # initialize model
-        if not os.path.exists(self.logdir):
-            os.mkdir(self.logdir)
-        self.model = None
-        self.trained = False
-        self.history = None
-
-        # build model
-        self._build_model(bypass, optimizer)
-
-    def fit(self, train_data, train_label, validation_data, validation_label,
-            batch_size, nb_epochs):
-        # TODO exploit 'sample_weight'
-        # TODO implement resumed training with 'initial_epoch'
-        # TODO add documentation
-
-        callbacks = []
-
-        # define checkpoints
-        if self.logdir is not None:
-            # create checkpoint callback
-            checkpoint_path = os.path.join(self.logdir, "cp-{epoch}.ckpt")
-            cp_callback = ModelCheckpoint(
-                filepath=checkpoint_path,
-                verbose=1)
-            callbacks.append(cp_callback)
-
-        # TODO debug early stopping
-        # define early stopping
-        early_stop = EarlyStopping(
-            monitor="val_categorical_accuracy",
-            min_delta=0,
-            patience=5,
-            verbose=2)
-        callbacks.append(early_stop)
-
-        # fit model
-        self.history = self.model.fit(
-            x=train_data,
-            y=train_label,
-            batch_size=batch_size,
-            epochs=nb_epochs,
-            verbose=2,
-            callbacks=callbacks,
-            validation_data=(validation_data, validation_label),
-            shuffle=True,
-            sample_weight=None,
-            initial_epoch=0)
-
-        # update model attribute
-        self.trained = True
-
-        return
-
-    def fit_generator(self, train_generator, validation_generator, nb_epochs,
-                      nb_workers=1, multiprocessing=False):
-        # TODO implement multiprocessing
-        # TODO exploit an equivalent of 'sample_weight'
-        # TODO implement resumed training with 'initial_epoch'
-        # TODO add documentation
-        # TODO check distribution strategy during compilation
-        # TODO check callbacks parameters
-        # check generators
-        if train_generator.nb_epoch_max is not None:
-            Warning("Train generator must loop indefinitely over the data. "
-                    "The parameter 'nb_epoch_max' is set to None.")
-            train_generator.nb_epoch_max = None
-        if validation_generator.nb_epoch_max is not None:
-            Warning("Validation generator must loop indefinitely over the "
-                    "data. The parameter 'nb_epoch_max' is set to None.")
-            validation_generator.nb_epoch_max = None
-
-        callbacks = []
-
-        # define checkpoints
-        if self.logdir is not None:
-            # create checkpoint callback
-            checkpoint_path = os.path.join(self.logdir, "cp-{epoch}.ckpt")
-            cp_callback = ModelCheckpoint(
-                filepath=checkpoint_path,
-                verbose=1)
-            callbacks.append(cp_callback)
-
-        # define early stopping
-        early_stop = EarlyStopping(
-            monitor='val_categorical_accuracy',
-            min_delta=0,
-            patience=5,
-            verbose=2)
-        callbacks.append(early_stop)
-
-        # fit model from generator
-        steps_per_epoch = train_generator.nb_batch_per_epoch
-        self.history = self.model.fit_generator(
-            generator=train_generator,
-            steps_per_epoch=steps_per_epoch,
-            epochs=nb_epochs,
-            verbose=2,
-            callbacks=callbacks,
-            validation_data=validation_generator,
-            validation_steps=validation_generator.nb_batch_per_epoch,
-            max_queue_size=10,
-            workers=nb_workers,
-            use_multiprocessing=multiprocessing,
-            initial_epoch=0)
-
-        # update model attribute
-        self.trained = True
-
-        return
-
-    def predict(self, data, return_probability=False):
-        # compute probabilities
-        probability = self.predict_probability(data=data)
-
-        # make prediction
-        prediction = np.argmax(probability, axis=-1)
-
-        if return_probability:
-            return prediction, probability
-        else:
-            return prediction
-
-    def predict_probability(self, data):
-        # compute probabilities
-        probability = self.model.predict(x=data)
-
-        return probability
-
-    def predict_generator(self, generator, return_probability=False,
-                          nb_workers=1, multiprocessing=False, verbose=0):
-        # compute probabilities
-        probability = self.predict_probability_generator(
-            generator=generator,
-            nb_workers=nb_workers,
-            multiprocessing=multiprocessing,
-            verbose=verbose)
-
-        # make prediction
-        prediction = np.argmax(probability, axis=-1)
-
-        if return_probability:
-            return prediction, probability
-        else:
-            return prediction
-
-    def predict_probability_generator(self, generator, nb_workers=1,
-                                      multiprocessing=False, verbose=0):
-        # TODO add multiprocessing
-        # compute probabilities
-        probability = self.model.predict_generator(
-            generator=generator,
-            steps=generator.nb_batch_per_epoch,
-            workers=nb_workers,
-            max_queue_size=1,
-            use_multiprocessing=multiprocessing,
-            verbose=verbose)
-
-        return probability
-
-    def evaluate(self, data, label, verbose=0):
-        # evaluate model
-        loss, accuracy = self.model.evaluate(x=data, y=label)
-        if verbose > 0:
-            print("Loss: {0:.3f} | Accuracy: {1:.3f}"
-                  .format(loss, 100 * accuracy))
-
-        return loss, accuracy
-
-    def evaluate_generator(self, generator, nb_workers=1,
-                           multiprocessing=False, verbose=0):
-        # TODO check the outcome 'loss' and 'accuracy'
-        # evaluate model
-        loss, accuracy = self.model.evaluate_generator(
-            generator=generator,
-            steps=generator.nb_batch_per_epoch,
-            workers=nb_workers,
-            max_queue_size=1,
-            use_multiprocessing=multiprocessing,
-            verbose=verbose)
-        if verbose > 0:
-            print("Loss: {0:.3f} | Accuracy: {1:.3f}"
-                  .format(loss, 100 * accuracy))
-
-        return loss, accuracy
-
-    def _build_model(self, bypass, optimizer):
-        # build model architecture
-        input_ = Input(shape=(224, 224, 3),
-                       name="input",
-                       dtype="float32")
-        logit_ = squeezenet_network_v0(input_tensor=input_,
-                                       nb_classes=self.nb_classes,
-                                       bypass=bypass)
-        output_ = squeezenet_classifier(logit=logit_)
-
-        self.model = Model(inputs=input_,
-                           outputs=output_,
-                           name="SqueezeNet_v0")
-
-        # get optimizer
-        self.optimizer = get_optimizer(optimizer_name=optimizer)
-
-        # compile model
-        self.model.compile(
-            optimizer=self.optimizer,
-            loss="categorical_crossentropy",
-            metrics=["categorical_accuracy"])
-
-    def print_model(self):
-        print(self.model.summary(), "\n")
-
-    def get_weight(self, latest=True, checkpoint_name="cp.ckpt"):
-        # TODO fix the loose of the optimizer state
-        # load weights from a training checkpoint if it exists
-        if self.logdir is not None and os.path.isdir(self.logdir):
-            # the last one...
-            if latest:
-                checkpoint_path = tf.train.latest_checkpoint(self.logdir)
-            # ...or a specific one
-            else:
-                checkpoint_path = os.path.join(self.logdir, checkpoint_name)
-
-            # load weights
-            self.model.load_weights(checkpoint_path)
-            self.trained = True
-
-        else:
-            raise ValueError("Impossible to load pre-trained weights. The log "
-                             "directory is not specified or does not exist.")
-
-    def save_training_history(self):
-        """Save the loss and accuracy of the train and validation data over
-        the different epochs.
-
-        Returns
-        -------
-
-        """
-        if self.logdir is not None:
-            path = os.path.join(self.logdir, "history.npz")
-            np.savez(path,
-                     loss=self.history.history["loss"],
-                     categorical_accuracy=self.history.history["loss"],
-                     val_loss=self.history.history["loss"],
-                     val_categorical_accuracy=self.history.history["loss"])
-
-        return
-
-    def get_feature_map(self, generator, after_average_pooling=True):
-        # TODO add documentation
-        # get input layer
-        input_ = self.model.input
-
-        # get embedding layer
-        if after_average_pooling:
-            output_ = self.model.layers[-2].output
-        else:
-            output_ = self.model.layers[-3].output
-
-        # define the steps to compute the feature map
-        features_map = function([input_, learning_phase()], [output_])
-
-        # compute the feature map
-        if generator.with_label:
-            embedding = [features_map([batch, 0])[0]
-                         for (batch, _) in generator]
-        else:
-            embedding = [features_map([batch, 0])[0]
-                         for batch in generator]
-        embedding = np.array(embedding)
-        embedding = np.concatenate(embedding, axis=0)
-
-        if not after_average_pooling:
-            a, b, c, d = embedding.shape
-            embedding = np.reshape(embedding, (a, b * c * d))
-
-        return embedding
-
-
-class SqueezeNet_qbi(BaseModel):
-    # TODO add documentation
-
-    def __init__(self, bypass=False, optimizer="adam",
-                 logdir=None):
-        # get model's attributes
-        super().__init__()
-        self.logdir = logdir
-
-        # initialize model
-        if not os.path.exists(self.logdir):
-            os.mkdir(self.logdir)
-        self.model = None
-        self.trained = False
-        self.history = None
-
-        # build model
-        self._build_model(bypass, optimizer)
-
-    def fit(self, train_data, train_label, validation_data, validation_label,
-            batch_size, nb_epochs):
-        # TODO exploit 'sample_weight'
-        # TODO implement resumed training with 'initial_epoch'
-        # TODO add documentation
-
-        callbacks = []
-
-        # define checkpoints
-        if self.logdir is not None:
-            # create checkpoint callback
-            checkpoint_path = os.path.join(self.logdir, "cp-{epoch}.ckpt")
-            cp_callback = ModelCheckpoint(
-                filepath=checkpoint_path,
-                verbose=1)
-            callbacks.append(cp_callback)
-
-        # TODO debug early stopping
-        # define early stopping
-        early_stop = EarlyStopping(
-            monitor="val_loss",
-            min_delta=0,
-            patience=5,
-            verbose=2)
-        callbacks.append(early_stop)
-
-        # fit model
-        self.history = self.model.fit(
-            x=train_data,
-            y=train_label,
-            batch_size=batch_size,
-            epochs=nb_epochs,
-            verbose=2,
-            callbacks=callbacks,
-            validation_data=(validation_data, validation_label),
-            shuffle=True,
-            sample_weight=None,
-            initial_epoch=0)
-
-        # update model attribute
-        self.trained = True
-
-        return
-
-    def fit_generator(self, train_generator, validation_generator, nb_epochs,
-                      nb_workers=1, multiprocessing=False):
-        # TODO implement multiprocessing
-        # TODO exploit an equivalent of 'sample_weight'
-        # TODO implement resumed training with 'initial_epoch'
-        # TODO add documentation
-        # TODO check distribution strategy during compilation
-        # TODO check callbacks parameters
-        # check generators
-        if train_generator.nb_epoch_max is not None:
-            Warning("Train generator must loop indefinitely over the data. "
-                    "The parameter 'nb_epoch_max' is set to None.")
-            train_generator.nb_epoch_max = None
-        if validation_generator.nb_epoch_max is not None:
-            Warning("Validation generator must loop indefinitely over the "
-                    "data. The parameter 'nb_epoch_max' is set to None.")
-            validation_generator.nb_epoch_max = None
-
-        callbacks = []
-
-        # define checkpoints
-        if self.logdir is not None:
-            # create checkpoint callback
-            checkpoint_path = os.path.join(self.logdir, "cp-{epoch}.ckpt")
-            cp_callback = ModelCheckpoint(
-                filepath=checkpoint_path,
-                verbose=1)
-            callbacks.append(cp_callback)
-
-        # define early stopping
-        early_stop = EarlyStopping(
-            monitor='accuracy',
-            min_delta=0,
-            patience=5,
-            verbose=2)
-        callbacks.append(early_stop)
-
-        # fit model from generator
-        steps_per_epoch = train_generator.nb_batch_per_epoch
-        self.history = self.model.fit_generator(
-            generator=train_generator,
-            steps_per_epoch=steps_per_epoch,
-            epochs=nb_epochs,
-            verbose=2,
-            callbacks=callbacks,
-            validation_data=validation_generator,
-            validation_steps=validation_generator.nb_batch_per_epoch,
-            max_queue_size=10,
-            workers=nb_workers,
-            use_multiprocessing=multiprocessing,
-            initial_epoch=0)
-
-        # update model attribute
-        self.trained = True
-
-        return
-
-    def predict(self, data, return_probability=False):
-        # compute probabilities
-        probability = self.predict_probability(data=data)
-
-        # make prediction
-        prediction = probability > 0.5
-
-        if return_probability:
-            return prediction, probability
-        else:
-            return prediction
-
-    def predict_probability(self, data):
-        # compute probabilities
-        probability = self.model.predict(x=data)
-
-        return probability
-
-    def predict_generator(self, generator, return_probability=False,
-                          nb_workers=1, multiprocessing=False, verbose=0):
-        # compute probabilities
-        probability = self.predict_probability_generator(
-            generator=generator,
-            nb_workers=nb_workers,
-            multiprocessing=multiprocessing,
-            verbose=verbose)
-
-        # make prediction
-        prediction = probability > 0.5
-
-        if return_probability:
-            return prediction, probability
-        else:
-            return prediction
-
-    def predict_probability_generator(self, generator, nb_workers=1,
-                                      multiprocessing=False, verbose=0):
-        # TODO add multiprocessing
-        # compute probabilities
-        probability = self.model.predict_generator(
-            generator=generator,
-            steps=generator.nb_batch_per_epoch,
-            workers=nb_workers,
-            max_queue_size=1,
-            use_multiprocessing=multiprocessing,
-            verbose=verbose)
-
-        return probability
-
-    def evaluate(self, data, label, verbose=0):
-        # evaluate model
-        loss, accuracy = self.model.evaluate(x=data, y=label)
-        if verbose > 0:
-            print("Loss: {0:.3f} | Accuracy: {1:.3f}"
-                  .format(loss, 100 * accuracy))
-
-        return loss, accuracy
-
-    def evaluate_generator(self, generator, nb_workers=1,
-                           multiprocessing=False, verbose=0):
-        # TODO check the outcome 'loss' and 'accuracy'
-        # evaluate model
-        loss, accuracy = self.model.evaluate_generator(
-            generator=generator,
-            steps=generator.nb_batch_per_epoch,
-            workers=nb_workers,
-            max_queue_size=1,
-            use_multiprocessing=multiprocessing,
-            verbose=verbose)
-        if verbose > 0:
-            print("Loss: {0:.3f} | Accuracy: {1:.3f}"
-                  .format(loss, 100 * accuracy))
-
-        return loss, accuracy
-
-    def _build_model(self, bypass, optimizer):
-        # build model architecture
-        input_ = Input(shape=(224, 224, 3),
-                       name="input",
-                       dtype="float32")
-        logits_ = squeezenet_network_qbi(input_tensor=input_,
-                                         bypass=bypass)
-
-        self.model = Model(inputs=input_,
-                           outputs=logits_,
-                           name="SqueezeNet_qbi")
-
-        # get optimizer
-        self.optimizer = get_optimizer(optimizer_name=optimizer)
-
-        # compile model
-        losses = {
-            "dense11_1": "binary_crossentropy",
-            "dense11_2": "binary_crossentropy",
-            "dense11_3": "binary_crossentropy",
-            "dense11_4": "binary_crossentropy",
-            "dense11_5": "binary_crossentropy"
-        }
-        self.model.compile(
-            optimizer=self.optimizer,
-            loss=losses,
-            metrics=["accuracy"])
-
-    def print_model(self):
-        print(self.model.summary(), "\n")
-
-    def get_weight(self, latest=True, checkpoint_name="cp.ckpt"):
-        # TODO fix the loose of the optimizer state
-        # load weights from a training checkpoint if it exists
-        if self.logdir is not None and os.path.isdir(self.logdir):
-            # the last one...
-            if latest:
-                checkpoint_path = tf.train.latest_checkpoint(self.logdir)
-            # ...or a specific one
-            else:
-                checkpoint_path = os.path.join(self.logdir, checkpoint_name)
-
-            # load weights
-            self.model.load_weights(checkpoint_path)
-            self.trained = True
-
-        else:
-            raise ValueError("Impossible to load pre-trained weights. The log "
-                             "directory is not specified or does not exist.")
-
-    def get_feature_map(self, data, after_average_pooling=True):
-        # TODO add documentation
-        # get input layer
-        input_ = self.model.input
-
-        # get embedding layer
-        if after_average_pooling:
-            output_ = self.model.layers[-6].output
-        else:
-            output_ = self.model.layers[-7].output
-
-        # define the steps to compute the feature map
-        features_map = function([input_, learning_phase()], [output_])
-
-        # compute the feature map
-        embedding = features_map([data, 0])
-        embedding = np.array(embedding, dtype=np.float32)[0]
-
-        if not after_average_pooling:
-            a, b, c, d = embedding.shape
-            embedding = np.reshape(embedding, (a, b * c * d))
-
-        return embedding
-
-
-# ### Architecture functions ###
-
-def squeezenet_network_v0(input_tensor, nb_classes, bypass=False):
-    """Original architecture of the network.
-
-    Parameters
-    ----------
-    input_tensor : Keras tensor, float32
-        Input tensor with shape (batch_size, 224, 224, 3).
-    nb_classes : int
-        Number of final classes.
-    bypass : bool
-        Use residual bypasses.
-
-    Returns
-    -------
-    tensor : Keras tensor, float32
-        Output tensor with shape (batch_size, nb_classes)
-
-    """
-    # first convolution block
-    padding1 = ZeroPadding2D(
-        padding=((2, 2), (2, 2)),
-        name="padding1")(
-        input_tensor)  # (batch_size, 228, 228, 3)
-    conv1 = Conv2D(
-        filters=96,
-        kernel_size=(7, 7),
-        strides=(2, 2),
-        activation='relu',
-        name='conv1')(
-        padding1)  # (batch_size, 111, 111, 96)
-    maxpool1 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool1")(
-        conv1)  # (batch_size, 55, 55, 96)
-
-    # fire modules
-    fire2 = fire_module(
-        input_tensor=maxpool1,
-        nb_filters_s1x1=16,
-        nb_filters_e1x1=64,
-        nb_filters_e3x3=64,
-        name="fire2")  # (batch_size, 55, 55, 128)
-    fire3 = fire_module(
-        input_tensor=fire2,
-        nb_filters_s1x1=16,
-        nb_filters_e1x1=64,
-        nb_filters_e3x3=64,
-        name="fire3")  # (batch_size, 55, 55, 128)
-    if bypass:
-        fire3 = Add()([fire2, fire3])
-    fire4 = fire_module(
-        input_tensor=fire3,
-        nb_filters_s1x1=32,
-        nb_filters_e1x1=128,
-        nb_filters_e3x3=128,
-        name="fire4")  # (batch_size, 55, 55, 256)
-    maxpool4 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool4")(
-        fire4)  # (batch_size, 27, 27, 256)
-    fire5 = fire_module(
-        input_tensor=maxpool4,
-        nb_filters_s1x1=32,
-        nb_filters_e1x1=128,
-        nb_filters_e3x3=128,
-        name="fire5")  # (batch_size, 27, 27, 256)
-    if bypass:
-        fire5 = Add()([maxpool4, fire5])
-    fire6 = fire_module(
-        input_tensor=fire5,
-        nb_filters_s1x1=48,
-        nb_filters_e1x1=192,
-        nb_filters_e3x3=192,
-        name="fire6")  # (batch_size, 27, 27, 384)
-    fire7 = fire_module(
-        input_tensor=fire6,
-        nb_filters_s1x1=48,
-        nb_filters_e1x1=192,
-        nb_filters_e3x3=192,
-        name="fire7")  # (batch_size, 27, 27, 384)
-    if bypass:
-        fire7 = Add()([fire6, fire7])
-    fire8 = fire_module(
-        input_tensor=fire7,
-        nb_filters_s1x1=64,
-        nb_filters_e1x1=256,
-        nb_filters_e3x3=256,
-        name="fire8")  # (batch_size, 27, 27, 512)
-    maxpool8 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool3")(
-        fire8)  # (batch_size, 13, 13, 512)
-    fire9 = fire_module(
-        input_tensor=maxpool8,
-        nb_filters_s1x1=64,
-        nb_filters_e1x1=256,
-        nb_filters_e3x3=256,
-        name="fire9")  # (batch_size, 13, 13, 512)
-    if bypass:
-        fire9 = Add()([maxpool8, fire9])
-
-    # last convolution block
-    dropout10 = Dropout(
-        rate=0.5,
-        name="dropout10")(
-        fire9)
-    conv10 = Conv2D(
-        filters=nb_classes,
-        kernel_size=(1, 1),
-        activation='relu',
-        name='conv10')(
-        dropout10)  # (batch_size, 13, 13, nb_classes)
-    norm10 = BatchNormalization(
-        name="batchnorm10")(
-        conv10)  # (batch_size, 13, 13, nb_classes)
-    avgpool10 = GlobalAveragePooling2D(
-        name="avgpool10")(
-        norm10)  # (batch_size, nb_classes)
-
-    return avgpool10
-
-
-def squeezenet_network_v1(input_tensor, nb_classes, bypass=False):
-    """A lighter architecture of the network.
-
-    Parameters
-    ----------
-    input_tensor : Keras tensor, float32
-        Input tensor with shape (batch_size, 224, 224, 3).
-    nb_classes : int
-        Number of final classes.
-    bypass : bool
-        Use residual bypasses.
-
-    Returns
-    -------
-    tensor : Keras tensor, float32
-        Output tensor with shape (batch_size, nb_classes)
-
-    """
-    # first convolution block
-    conv1 = Conv2D(
-        filters=64,
-        kernel_size=(3, 3),
-        strides=(2, 2),
-        activation='relu',
-        name='conv1')(
-        input_tensor)  # (batch_size, 111, 111, 64)
-    maxpool1 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool1")(
-        conv1)  # (batch_size, 55, 55, 64)
-
-    # fire modules
-    fire2 = fire_module(
-        input_tensor=maxpool1,
-        nb_filters_s1x1=16,
-        nb_filters_e1x1=64,
-        nb_filters_e3x3=64,
-        name="fire2")  # (batch_size, 55, 55, 128)
-    fire3 = fire_module(
-        input_tensor=fire2,
-        nb_filters_s1x1=16,
-        nb_filters_e1x1=64,
-        nb_filters_e3x3=64,
-        name="fire3")  # (batch_size, 55, 55, 128)
-    if bypass:
-        fire3 = Add()([fire2, fire3])
-    maxpool3 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool3")(
-        fire3)  # (batch_size, 27, 27, 128)
-    fire4 = fire_module(
-        input_tensor=maxpool3,
-        nb_filters_s1x1=32,
-        nb_filters_e1x1=128,
-        nb_filters_e3x3=128,
-        name="fire4")  # (batch_size, 27, 27, 256)
-    fire5 = fire_module(
-        input_tensor=fire4,
-        nb_filters_s1x1=32,
-        nb_filters_e1x1=128,
-        nb_filters_e3x3=128,
-        name="fire5")  # (batch_size, 27, 27, 256)
-    if bypass:
-        fire5 = Add()([fire4, fire5])
-    maxpool5 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool5")(
-        fire5)  # (batch_size, 13, 13, 256)
-    fire6 = fire_module(
-        input_tensor=maxpool5,
-        nb_filters_s1x1=48,
-        nb_filters_e1x1=192,
-        nb_filters_e3x3=192,
-        name="fire6")  # (batch_size, 13, 13, 384)
-    fire7 = fire_module(
-        input_tensor=fire6,
-        nb_filters_s1x1=48,
-        nb_filters_e1x1=192,
-        nb_filters_e3x3=192,
-        name="fire7")  # (batch_size, 13, 13, 384)
-    if bypass:
-        fire7 = Add()([fire6, fire7])
-    fire8 = fire_module(
-        input_tensor=fire7,
-        nb_filters_s1x1=64,
-        nb_filters_e1x1=256,
-        nb_filters_e3x3=256,
-        name="fire8")  # (batch_size, 13, 13, 512)
-    fire9 = fire_module(
-        input_tensor=fire8,
-        nb_filters_s1x1=64,
-        nb_filters_e1x1=256,
-        nb_filters_e3x3=256,
-        name="fire9")  # (batch_size, 13, 13, 512)
-    if bypass:
-        fire9 = Add()([fire8, fire9])
-
-    # last convolution block
-    dropout10 = Dropout(
-        rate=0.5,
-        name="dropout10")(
-        fire9)
-    conv10 = Conv2D(
-        filters=nb_classes,
-        kernel_size=(1, 1),
-        activation='relu',
-        name='conv10')(
-        dropout10)  # (batch_size, 13, 13, nb_classes)
-    avgpool10 = GlobalAveragePooling2D(
-        name="avgpool10")(
-        conv10)  # (batch_size, nb_classes)
-
-    return avgpool10
-
-
-def squeezenet_network_qbi(input_tensor, bypass=False):
-    """A lighter architecture of the network.
-
-    Parameters
-    ----------
-    input_tensor : Keras tensor, float32
-        Input tensor with shape (batch_size, 224, 224, 3).
-    bypass : bool
-        Use residual bypasses.
-
-    Returns
-    -------
-    dense11_1 : Keras tensor, float32
-        Output tensor with shape (batch_size, 1)
-    dense11_2 : Keras tensor, float32
-        Output tensor with shape (batch_size, 1)
-    dense11_3 : Keras tensor, float32
-        Output tensor with shape (batch_size, 1)
-    dense11_4 : Keras tensor, float32
-        Output tensor with shape (batch_size, 1)
-    dense11_5 : Keras tensor, float32
-        Output tensor with shape (batch_size, 1)
-
-    """
-    # first convolution block
-    conv1 = Conv2D(
-        filters=64,
-        kernel_size=(3, 3),
-        strides=(2, 2),
-        activation='relu',
-        name='conv1')(
-        input_tensor)  # (batch_size, 111, 111, 64)
-    maxpool1 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool1")(
-        conv1)  # (batch_size, 55, 55, 64)
-
-    # fire modules
-    fire2 = fire_module(
-        input_tensor=maxpool1,
-        nb_filters_s1x1=16,
-        nb_filters_e1x1=64,
-        nb_filters_e3x3=64,
-        name="fire2")  # (batch_size, 55, 55, 128)
-    fire3 = fire_module(
-        input_tensor=fire2,
-        nb_filters_s1x1=16,
-        nb_filters_e1x1=64,
-        nb_filters_e3x3=64,
-        name="fire3")  # (batch_size, 55, 55, 128)
-    if bypass:
-        fire3 = Add()([fire2, fire3])
-    maxpool3 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool3")(
-        fire3)  # (batch_size, 27, 27, 128)
-    fire4 = fire_module(
-        input_tensor=maxpool3,
-        nb_filters_s1x1=32,
-        nb_filters_e1x1=128,
-        nb_filters_e3x3=128,
-        name="fire4")  # (batch_size, 27, 27, 256)
-    fire5 = fire_module(
-        input_tensor=fire4,
-        nb_filters_s1x1=32,
-        nb_filters_e1x1=128,
-        nb_filters_e3x3=128,
-        name="fire5")  # (batch_size, 27, 27, 256)
-    if bypass:
-        fire5 = Add()([fire4, fire5])
-    maxpool5 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool5")(
-        fire5)  # (batch_size, 13, 13, 256)
-    fire6 = fire_module(
-        input_tensor=maxpool5,
-        nb_filters_s1x1=48,
-        nb_filters_e1x1=192,
-        nb_filters_e3x3=192,
-        name="fire6")  # (batch_size, 13, 13, 384)
-    fire7 = fire_module(
-        input_tensor=fire6,
-        nb_filters_s1x1=48,
-        nb_filters_e1x1=192,
-        nb_filters_e3x3=192,
-        name="fire7")  # (batch_size, 13, 13, 384)
-    if bypass:
-        fire7 = Add()([fire6, fire7])
-    fire8 = fire_module(
-        input_tensor=fire7,
-        nb_filters_s1x1=64,
-        nb_filters_e1x1=256,
-        nb_filters_e3x3=256,
-        name="fire8")  # (batch_size, 13, 13, 512)
-    fire9 = fire_module(
-        input_tensor=fire8,
-        nb_filters_s1x1=64,
-        nb_filters_e1x1=256,
-        nb_filters_e3x3=256,
-        name="fire9")  # (batch_size, 13, 13, 512)
-    if bypass:
-        fire9 = Add()([fire8, fire9])
-
-    # last convolution block
-    dropout10 = Dropout(
-        rate=0.5,
-        name="dropout10")(
-        fire9)
-    conv10 = Conv2D(
-        filters=512,
-        kernel_size=(1, 1),
-        activation='relu',
-        name='conv10')(
-        dropout10)  # (batch_size, 13, 13, 512)
-    avgpool10 = GlobalAveragePooling2D(
-        name="avgpool10")(
-        conv10)  # (batch_size, 512)
-
-    # multi-heads
-    dense11_1 = Dense(
-        units=1,
-        activation="sigmoid",
-        name="dense11_1")(
-        avgpool10)  # (batch_size, 1)
-    dense11_2 = Dense(
-        units=1,
-        activation="sigmoid",
-        name="dense11_2")(
-        avgpool10)  # (batch_size, 1)
-    dense11_3 = Dense(
-        units=1,
-        activation="sigmoid",
-        name="dense11_3")(
-        avgpool10)  # (batch_size, 1)
-    dense11_4 = Dense(
-        units=1,
-        activation="sigmoid",
-        name="dense11_4")(
-        avgpool10)  # (batch_size, 1)
-    dense11_5 = Dense(
-        units=1,
-        activation="sigmoid",
-        name="dense11_5")(
-        avgpool10)  # (batch_size, 1)
-
-    return [dense11_1, dense11_2, dense11_3, dense11_4, dense11_5]
-
-
-def fire_module(input_tensor, nb_filters_s1x1, nb_filters_e1x1,
-                nb_filters_e3x3, name):
-    """Fire module.
-
-    A first convolution 2-d 1x1 reduces the depth of the input tensor (squeeze
-    layer). It then allows us to 1) replace 3x3 filters by 1x1 filters and 2)
-    decrease the number of input channels to 3x3 filters (expand layer). To
-    define a convolution step with different kernel size (1x1 and 3x3), we use
-    two different convolution layers, then we concatenate their results along
-    the channel dimension (output layer).
-
-    Parameters
-    ----------
-    input_tensor : Keras tensor, float32
-        Input tensor with shape (batch_size, height, width, channels).
-    nb_filters_s1x1 : int
-        Number of filters of the squeeze layer (1x1 Conv2D).
-    nb_filters_e1x1 : int
-        Number of filters of the expand layer (1x1 Conv2D).
-    nb_filters_e3x3 : int
-        Number of filters of the expand layer (3x3 Conv2D).
-    name : str
-        Name of these layers.
-
-    Returns
-    -------
-    output_layer : Keras tensor, float32
-        Output tensor with shape
-        (batch_size, height, width, nb_filters_e1x1 + nb_filters_e3x3)).
-
-    """
-    # squeeze layer
-    squeeze_layer = Conv2D(
-        filters=nb_filters_s1x1,
-        kernel_size=(1, 1),
-        activation="relu",
-        name="{0}_s1x1".format(name))(
-        input_tensor)
-
-    # expand layer
-    expand_layer_1x1 = Conv2D(
-        filters=nb_filters_e1x1,
-        kernel_size=(1, 1),
-        activation="relu",
-        name="{0}_e1x1".format(name))(
-        squeeze_layer)
-    expand_layer_3x3 = Conv2D(
-        filters=nb_filters_e3x3,
-        kernel_size=(3, 3),
-        activation="relu",
-        padding="same",
-        name="{0}_e3x3".format(name))(
-        squeeze_layer)
-
-    # output layer
-    output_layer = Concatenate(
-        axis=-1,
-        name="{0}_output".format(name))(
-        [expand_layer_1x1, expand_layer_3x3])
-
-    return output_layer
-
-
-def squeezenet_classifier(logit):
-    """Normalized logit using softmax function.
-
-    Parameters
-    ----------
-    logit : Keras tensor, float32
-        Output layer of the network.
-
-    Returns
-    -------
-    normalized_logit : Keras tensor, float32
-        Normalized output of the network, between 0 and 1.
-
-    """
-    # softmax
-    normalized_logit = Activation(activation="softmax", name="softmax")(logit)
-
-    return normalized_logit
-
-
-#from keras import backend as K
-#import numpy as np
-
-
-#nS = 100 # number of Monte Carlo samples
-#MC_output = K.function([model.layers[0].input, K.learning_phase()], [model.layers[-1].output])
-#learning_phase = True  # use dropout at test time
-#MC_samples = [MC_output([x_test, learning_phase])[0] for _ in range(nS)]
-#MC_samples = np.array(MC_samples)
-## print(MC_samples.shape)
-
-#predictions = np.mean(MC_samples,axis=0)
-#y_preds = np.argmax(predictions, axis=1)
-#nberr_S = np.where(y_preds != y_test, 1.0, 0.0).sum()
-#print("nb errors MC dropout="+str(nberr_S))
-
-#np.save("MC_samples_dropout", MC_samples)
\ No newline at end of file
diff --git a/bigfish/deep_learning/unet.py b/bigfish/deep_learning/unet.py
deleted file mode 100644
index 6871ff2f..00000000
--- a/bigfish/deep_learning/unet.py
+++ /dev/null
@@ -1,362 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""
-Models based on U-net.
-
-Paper: "U-Net: Convolutional Networks for Biomedical Image Segmentation"
-Authors: Ronneberger, Olaf
-         Fischer, Philipp
-         Brox, Thomas
-Year: 2015
-
-Page: Deconvolution and Checkerboard Artifacts
-Authors: Odena, Augustus
-         Dumoulin, Vincent
-         Olah, Chris
-Year: 2016
-Link: http://doi.org/10.23915/distill.00003
-"""
-
-import os
-
-import tensorflow as tf
-import numpy as np
-
-#from .base import BaseModel, get_optimizer
-
-from tensorflow.python.keras.backend import function, learning_phase
-from tensorflow.python.keras.models import Model
-from tensorflow.python.keras.callbacks import ModelCheckpoint, EarlyStopping
-from tensorflow.python.keras.layers import (Conv2D, Concatenate, MaxPooling2D,
-                                            Dropout, GlobalAveragePooling2D,
-                                            Add, Input, Activation,
-                                            ZeroPadding2D, BatchNormalization,
-                                            Cropping2D)
-
-# TODO add logging routines
-# TODO add cache routines
-# TODO manage multiprocessing
-# TODO improve logging
-# ### 2D models ###
-
-
-# ### Architecture functions ###
-
-def unet_network(input_tensor, nb_classes):
-    """Original architecture of the network.
-
-    Parameters
-    ----------
-    input_tensor : Keras tensor, float32
-        Input tensor with shape (batch_size, ?, ?, 1).
-    nb_classes : int
-        Number of final classes.
-
-    Returns
-    -------
-    tensor : Keras tensor, float32
-        Output tensor with shape (batch_size, ?, ?, nb_classes)
-
-    """
-    # contraction 1
-    conv1 = Conv2D(
-        filters=64,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv1')(
-        input_tensor)  # (batch_size, ?, ?, 64)
-    conv2 = Conv2D(
-        filters=64,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv2')(
-        conv1)  # (batch_size, ?, ?, 64)
-    crop2 = Cropping2D(
-        cropping=((88, 88), (88, 88)),
-        name="crop2")(
-        conv2)  # (batch_size, ?, ?, 64)
-    maxpool2 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool2")(
-        conv2)  # (batch_size, ?, ?, 64)
-
-    # contraction 2
-    conv3 = Conv2D(
-        filters=128,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv3')(
-        maxpool2)  # (batch_size, ?, ?, 128)
-    conv4 = Conv2D(
-        filters=128,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv4')(
-        conv3)  # (batch_size, ?, ?, 128)
-    crop4 = Cropping2D(
-        cropping=((40, 40), (40, 40)),
-        name="crop4")(
-        conv4)  # (batch_size, ?, ?, 128)
-    maxpool4 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool4")(
-        conv4)  # ((batch_size, ?, ?, 128)
-
-    # contraction 3
-    conv5 = Conv2D(
-        filters=256,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv5')(
-        maxpool4)  # (batch_size, ?, ?, 256)
-    conv6 = Conv2D(
-        filters=256,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv6')(
-        conv5)  # (batch_size, ?, ?, 256)
-    crop6 = Cropping2D(
-        cropping=((16, 16), (16, 16)),
-        name="crop6")(
-        conv6)  # (batch_size, ?, ?, 256)
-    maxpool6 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool6")(
-        conv6)  # (batch_size, ?, ?, 256)
-
-    # contraction 4
-    conv7 = Conv2D(
-        filters=512,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv7')(
-        maxpool6)  # (batch_size, ?, ?, 512)
-    conv8 = Conv2D(
-        filters=512,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv8')(
-        conv7)  # (batch_size, ?, ?, 512)
-    crop8 = Cropping2D(
-        cropping=((4, 4), (4, 4)),
-        name="crop8")(
-        conv8)  # (batch_size, ?, ?, 512)
-    maxpool8 = MaxPooling2D(
-        pool_size=(3, 3),
-        strides=(2, 2),
-        name="maxpool8")(
-        conv8)  # (batch_size, ?, ?, 512)
-
-    # bottom
-    conv9 = Conv2D(
-        filters=1024,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv9')(
-        maxpool8)  # (batch_size, ?, ?, 1024)
-    conv10 = Conv2D(
-        filters=1024,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv10')(
-        conv9)  # (batch_size, ?, ?, 1024)
-
-    # expansion 1
-    upconv11 = up_conv_2d(
-        input_tensor=conv10,
-        nb_filters=512,
-        name='upconv11')  # (batch_size, ?, ?, 512)
-    concat11 = tf.concat(
-        values=[crop8, upconv11],
-        axis=-1,
-        name='concat11')  # (batch_size, ?, ?, 1024)
-    conv12 = Conv2D(
-        filters=512,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv12')(
-        concat11)  # (batch_size, ?, ?, 512)
-    conv13 = Conv2D(
-        filters=512,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv13')(
-        conv12)  # (batch_size, ?, ?, 512)
-
-    # expansion 2
-    upconv14 = up_conv_2d(
-        input_tensor=conv13,
-        nb_filters=256,
-        name='upconv14')  # (batch_size, ?, ?, 256)
-    concat14 = tf.concat(
-        values=[crop6, upconv14],
-        axis=-1,
-        name='concat14')  # (batch_size, ?, ?, 512)
-    conv15 = Conv2D(
-        filters=256,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv15')(
-        concat14)  # (batch_size, ?, ?, 256)
-    conv16 = Conv2D(
-        filters=256,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv16')(
-        conv15)  # (batch_size, ?, ?, 256)
-
-    # expansion 3
-    upconv17 = up_conv_2d(
-        input_tensor=conv16,
-        nb_filters=128,
-        name='upconv17')  # (batch_size, ?, ?, 128)
-    concat17 = tf.concat(
-        values=[crop4, upconv17],
-        axis=-1,
-        name='concat17')  # (batch_size, ?, ?, 256)
-    conv18 = Conv2D(
-        filters=128,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv18')(
-        concat17)  # (batch_size, ?, ?, 128)
-    conv19 = Conv2D(
-        filters=128,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv19')(
-        conv18)  # (batch_size, ?, ?, 128)
-
-    # expansion 4
-    upconv20 = up_conv_2d(
-        input_tensor=conv19,
-        nb_filters=64,
-        name='upconv20')  # (batch_size, ?, ?, 64)
-    concat20 = tf.concat(
-        values=[crop2, upconv20],
-        axis=-1,
-        name='concat20')  # (batch_size, ?, ?, 128)
-    conv21 = Conv2D(
-        filters=64,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv21')(
-        concat20)  # (batch_size, ?, ?, 64)
-    conv22 = Conv2D(
-        filters=64,
-        kernel_size=(3, 3),
-        activation='relu',
-        name='conv22')(
-        conv21)  # (batch_size, ?, ?, 64)
-    conv23 = Conv2D(
-        filters=nb_classes,
-        kernel_size=(1, 1),
-        activation='sigmoid',
-        name='conv23')(
-        conv22)  # (batch_size, ?, ?, nb_classes)
-
-    return conv23
-
-
-#norm10 = BatchNormalization(
-#        name="batchnorm10")(
-#        conv10)  # (batch_size, 13, 13, nb_classes)
-
-#dropout10 = Dropout(
-#        rate=0.5,
-#        name="dropout10")(
-#        fire9)
-
-
-def up_conv_2d(input_tensor, nb_filters, name):
-    """Fire module.
-
-    1) Tensor is resized by a factor 2 using nearest neighbors.
-    2) Tensor is padded with a symmetric mode to avoid boundary artifacts.
-    3) A 2-d convolution with a 3x3 filter is applied. In the original article
-    the convolution has a 2x2 filter.
-
-    Parameters
-    ----------
-    input_tensor : Keras tensor, float32
-        Input tensor with shape (batch_size, height, width, channels).
-    nb_filters : int
-        Number of filters of the convolution layer.
-    name : str
-        Name of these layers.
-
-    Returns
-    -------
-    output_layer : Keras tensor, float32
-        Output tensor with shape (batch_size, 2 * height, 2 * width, channels).
-
-    """
-    resize = UpSampling2D(size=(2, 2), interpolation='nearest')(input_tensor)
-    paddings = tf.constant([[0, 0], [1, 1], [1, 1], [0, 0]])
-    resize = tf.pad(resize, paddings, "SYMMETRIC")
-    output_layer = Conv2D(
-        filters=nb_filters,
-        kernel_size=(3, 3),
-        activation='relu',
-        name=name)(
-        resize)
-
-    return output_layer
-
-
-def get_input_size_unet(bottom_size):
-    """Compute the input size required to have a specific bottom size.
-
-    Parameters
-    ----------
-    bottom_size : int
-        Tensor size at the bottom of the U-net model.
-
-    Returns
-    -------
-    input_size : int
-        Input size required to get the specified bottom size.
-
-    """
-    # compute the relation between the input size and the bottom size
-    input_size = 4 + 2 * (4 + 2 * (4 + 2 * (4 + 2 * bottom_size)))
-
-    return input_size
-
-
-
-########################################
-
-
-
-
-def depthwise_softmax(x):
-    exp_tensor = K.exp(x - K.max(x, axis=-1, keepdims=True))
-    # softmax_tensor = exp_tensor / K.sum(exp_tensor, axis=-1, keepdims=True)
-
-    return exp_tensor / K.sum(exp_tensor, axis=-1, keepdims=True)
-
-
-def channelwise_structure(radiuses):
-    np_structure = numpy.ones(
-        (2 * max(radiuses) + 1, 2 * max(radiuses) + 1, len(radiuses)))
-    structures = []
-    np_structure = numpy.stack([erosion(disk(radius), disk(radius)),
-                                erosion(disk(radius), disk(radius)),
-                                disk(radius)], axis=-1)
-    structure = tf.constant(np_structure, dtype='float32')
-    return structure
-
-
-def binary_closing(input, structure):
-    dilated = tf.nn.dilation2d(input, structure, [1, 1, 1, 1], [1, 1, 1, 1],
-                               padding="SAME")
-
-    eroded = tf.nn.erosion2d(dilated, structure, [1, 1, 1, 1], [1, 1, 1, 1],
-                             padding="SAME")
-
-    return eroded
-
diff --git a/bigfish/deep_learning/utils.py b/bigfish/deep_learning/utils.py
deleted file mode 100644
index 2be2bafc..00000000
--- a/bigfish/deep_learning/utils.py
+++ /dev/null
@@ -1,267 +0,0 @@
-from optparse import OptionParser
-from skimage.measure import label
-from sklearn.metrics import accuracy_score, roc_auc_score
-from sklearn.metrics import jaccard_similarity_score, f1_score
-from sklearn.metrics import recall_score, precision_score, confusion_matrix
-from skimage.morphology import erosion, disk
-from os.path import join
-import os
-from skimage.io import imsave, imread
-import numpy as np
-import pdb
-import time
-from progressbar import ProgressBar
-from postproc.postprocessing import PostProcess, generate_wsl
-
-
-def GetOptions():
-    """
-    Defines most of the options needed
-    """
-    parser = OptionParser()
-    parser.add_option("--tf_record", dest="TFRecord", type="string",
-                      default="",
-                      help="Where to find the TFrecord file")
-    parser.add_option("--path", dest="path", type="string",
-                      help="Where to collect the patches")
-    parser.add_option("--size_train", dest="size_train", type="int",
-                      help="size of the input image to the network")
-    parser.add_option("--log", dest="log",
-                      help="log dir")
-    parser.add_option("--learning_rate", dest="lr", type="float", default=0.01,
-                      help="learning_rate")
-    parser.add_option("--batch_size", dest="bs", type="int", default=1,
-                      help="batch size")
-    parser.add_option("--epoch", dest="epoch", type="int", default=1,
-                      help="number of epochs")
-    parser.add_option("--n_features", dest="n_features", type="int",
-                      help="number of channels on first layers")
-    parser.add_option("--weight_decay", dest="weight_decay", type="float",
-                      default=0.00005,
-                      help="weight decay value")
-    parser.add_option("--dropout", dest="dropout", type="float",
-                      default=0.5,
-                      help="dropout value to apply to the FC layers.")
-    parser.add_option("--mean_file", dest="mean_file", type="str",
-                      help="where to find the mean file to substract to the original image.")
-    parser.add_option('--n_threads', dest="THREADS", type=int, default=100,
-                      help="number of threads to use for the preprocessing.")
-    parser.add_option('--crop', dest="crop", type=int, default=4,
-                      help="crop size depending on validation/test/train phase.")
-    parser.add_option('--split', dest="split", type="str",
-                      help="validation/test/train phase.")
-    parser.add_option('--p1', dest="p1", type="int",
-                      help="1st input for post processing.")
-    parser.add_option('--p2', dest="p2", type="float",
-                      help="2nd input for post processing.")
-    parser.add_option('--iters', dest="iters", type="int")
-    parser.add_option('--seed', dest="seed", type="int")
-    parser.add_option('--size_test', dest="size_test", type="int")
-    parser.add_option('--restore', dest="restore", type="str")
-    parser.add_option('--save_path', dest="save_path", type="str", default=".")
-    parser.add_option('--type', dest="type", type="str",
-                      help="Type for the datagen")
-    parser.add_option('--UNet', dest='UNet', action='store_true')
-    parser.add_option('--no-UNet', dest='UNet', action='store_false')
-    parser.add_option('--output', dest="output", type="str")
-    parser.add_option('--output_csv', dest="output_csv", type="str")
-
-    (options, args) = parser.parse_args()
-
-    return options
-
-
-def ComputeMetrics(prob, batch_labels, p1, p2, rgb=None, save_path=None,
-                   ind=0):
-    """
-    Computes all metrics between probability map and corresponding label.
-    If you give also an rgb image it will save many extra meta data image.
-    """
-    GT = label(batch_labels.copy())
-    PRED = PostProcess(prob, p1, p2)
-    # PRED = label((prob > 0.5).astype('uint8'))
-    lbl = GT.copy()
-    pred = PRED.copy()
-    aji = AJI_fast(lbl, pred)
-    lbl[lbl > 0] = 1
-    pred[pred > 0] = 1
-    l, p = lbl.flatten(), pred.flatten()
-    acc = accuracy_score(l, p)
-    roc = roc_auc_score(l, p)
-    jac = jaccard_similarity_score(l, p)
-    f1 = f1_score(l, p)
-    recall = recall_score(l, p)
-    precision = precision_score(l, p)
-    if rgb is not None:
-        xval_n = join(save_path, "xval_{}.png").format(ind)
-        yval_n = join(save_path, "yval_{}.png").format(ind)
-        prob_n = join(save_path, "prob_{}.png").format(ind)
-        pred_n = join(save_path, "pred_{}.png").format(ind)
-        c_gt_n = join(save_path, "C_gt_{}.png").format(ind)
-        c_pr_n = join(save_path, "C_pr_{}.png").format(ind)
-
-        imsave(xval_n, rgb)
-        imsave(yval_n, color_bin(GT))
-        imsave(prob_n, prob)
-        imsave(pred_n, color_bin(PRED))
-        imsave(c_gt_n, add_contours(rgb, GT))
-        imsave(c_pr_n, add_contours(rgb, PRED))
-
-    return acc, roc, jac, recall, precision, f1, aji
-
-
-def color_bin(bin_labl):
-    """
-    Colors bin image so that nuclei come out nicer.
-    """
-    dim = bin_labl.shape
-    x, y = dim[0], dim[1]
-    res = np.zeros(shape=(x, y, 3))
-    for i in range(1, bin_labl.max() + 1):
-        rgb = np.random.normal(loc=125, scale=100, size=3)
-        rgb[rgb < 0] = 0
-        rgb[rgb > 255] = 255
-        rgb = rgb.astype(np.uint8)
-        res[bin_labl == i] = rgb
-    return res.astype(np.uint8)
-
-
-def add_contours(rgb_image, contour, ds=2):
-    """
-    Adds contours to images.
-    The image has to be a binary image
-    """
-    rgb = rgb_image.copy()
-    contour[contour > 0] = 1
-    boundery = contour - erosion(contour, disk(ds))
-    rgb[boundery > 0] = np.array([0, 0, 0])
-    return rgb
-
-
-def CheckOrCreate(path):
-    """
-    If path exists, does nothing otherwise it creates it.
-    """
-    if not os.path.isdir(path):
-        os.makedirs(path)
-
-
-def Intersection(A, B):
-    """
-    Returns the pixel count corresponding to the intersection
-    between A and B.
-    """
-    C = A + B
-    C[C != 2] = 0
-    C[C == 2] = 1
-    return C
-
-
-def Union(A, B):
-    """
-    Returns the pixel count corresponding to the union
-    between A and B.
-    """
-    C = A + B
-    C[C > 0] = 1
-    return C
-
-
-def AssociatedCell(G_i, S):
-    """
-    Returns the indice of the associated prediction cell for a certain
-    ground truth element. Maybe do something if no associated cell in the
-    prediction mask touches the GT.
-    """
-
-    def g(indice):
-        S_indice = np.zeros_like(S)
-        S_indice[S == indice] = 1
-        NUM = float(Intersection(G_i, S_indice).sum())
-        DEN = float(Union(G_i, S_indice).sum())
-        return NUM / DEN
-
-    res = map(g, range(1, S.max() + 1))
-    indice = np.array(res).argmax() + 1
-    return indice
-
-
-pbar = ProgressBar()
-
-
-def AJI(G, S):
-    """
-    AJI as described in the paper, AJI is more abstract implementation but 100times faster.
-    """
-    G = label(G, background=0)
-    S = label(S, background=0)
-
-    C = 0
-    U = 0
-    USED = np.zeros(S.max())
-
-    for i in pbar(range(1, G.max() + 1)):
-        only_ground_truth = np.zeros_like(G)
-        only_ground_truth[G == i] = 1
-        j = AssociatedCell(only_ground_truth, S)
-        only_prediction = np.zeros_like(S)
-        only_prediction[S == j] = 1
-        C += Intersection(only_prediction, only_ground_truth).sum()
-        U += Union(only_prediction, only_ground_truth).sum()
-        USED[j - 1] = 1
-
-    def h(indice):
-        if USED[indice - 1] == 1:
-            return 0
-        else:
-            only_prediction = np.zeros_like(S)
-            only_prediction[S == indice] = 1
-        return only_prediction.sum()
-
-    U_sum = map(h, range(1, S.max() + 1))
-    U += np.sum(U_sum)
-    return float(C) / float(U)
-
-
-def AJI_fast(G, S):
-    """
-    AJI as described in the paper, but a much faster implementation.
-    """
-    G = label(G, background=0)
-    S = label(S, background=0)
-    if S.sum() == 0:
-        return 0.
-    C = 0
-    U = 0
-    USED = np.zeros(S.max())
-
-    G_flat = G.flatten()
-    S_flat = S.flatten()
-    G_max = np.max(G_flat)
-    S_max = np.max(S_flat)
-    m_labels = max(G_max, S_max) + 1
-    cm = confusion_matrix(G_flat, S_flat, labels=range(m_labels)).astype(
-        np.float)
-    LIGNE_J = np.zeros(S_max)
-    for j in range(1, S_max + 1):
-        LIGNE_J[j - 1] = cm[:, j].sum()
-
-    for i in range(1, G_max + 1):
-        LIGNE_I_sum = cm[i, :].sum()
-
-        def h(indice):
-            LIGNE_J_sum = LIGNE_J[indice - 1]
-            inter = cm[i, indice]
-
-            union = LIGNE_I_sum + LIGNE_J_sum - inter
-            return inter / union
-
-        JI_ligne = map(h, range(1, S_max + 1))
-        best_indice = np.argmax(JI_ligne) + 1
-        C += cm[i, best_indice]
-        U += LIGNE_J[best_indice - 1] + LIGNE_I_sum - cm[i, best_indice]
-        USED[best_indice - 1] = 1
-
-    U_sum = ((1 - USED) * LIGNE_J).sum()
-    U += U_sum
-    return float(C) / float(U)
\ No newline at end of file
diff --git a/bigfish/deep_learning/utils_models.py b/bigfish/deep_learning/utils_models.py
new file mode 100644
index 00000000..a3453a2e
--- /dev/null
+++ b/bigfish/deep_learning/utils_models.py
@@ -0,0 +1,365 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Segmentation models (utility functions).
+"""
+
+import tensorflow_addons as tfa
+
+from tensorflow.python.keras.layers import Conv2D
+from tensorflow.python.keras.layers import MaxPooling2D
+from tensorflow.python.keras.layers import Add
+from tensorflow.python.keras.layers import Activation
+from tensorflow.python.keras.layers import UpSampling2D
+from tensorflow.python.keras.engine.training import Model
+
+
+# ### Convolution blocks ###
+
+class SameConv(Model):
+
+    def __init__(self,
+                 filters,
+                 kernel_size=(3, 3),
+                 normalization=True,
+                 activation="relu",
+                 **kwargs):
+        super(SameConv, self).__init__(**kwargs)
+
+        # initialize parameters
+        self.filters = filters
+        self.kernel_size = kernel_size
+        self.normalization = normalization
+        self.activation = activation
+
+        # define layers
+        self.conv = Conv2D(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            padding='same')
+        if self.normalization:
+            self.norm = tfa.layers.InstanceNormalization()
+        else:
+            self.norm = None
+        self.act = Activation(
+            activation=self.activation)
+
+    def call(self, inputs, training=False, mask=None):
+        # compute layers
+        x = self.conv(inputs)
+        if self.normalization:
+            x = self.norm(x)
+        x = self.act(x)
+
+        return x
+
+    def get_config(self):
+        config = {
+            'filters': self.filters,
+            'kernel_size': self.kernel_size,
+            'normalization': self.normalization,
+            'activation': self.activation}
+
+        return config
+
+
+class UpConv(Model):
+
+    def __init__(self,
+                 filters,
+                 kernel_size=(3, 3),
+                 normalization=True,
+                 activation="relu",
+                 interpolation="bilinear",
+                 **kwargs):
+        super(UpConv, self).__init__(**kwargs)
+
+        # initialize parameters
+        self.filters = filters
+        self.kernel_size = kernel_size
+        self.normalization = normalization
+        self.activation = activation
+        self.interpolation = interpolation
+
+        # define layers
+        self.resize = UpSampling2D(
+            size=(2, 2),
+            interpolation=self.interpolation)
+        self.conv = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+
+    def call(self, inputs, training=False, mask=None):
+        # resize and convolve image
+        x = self.resize(inputs)
+        x = self.conv(x)
+
+        return x
+
+    def get_config(self):
+        config = {
+            'filters': self.filters,
+            'kernel_size': self.kernel_size,
+            'normalization': self.normalization,
+            'activation': self.activation,
+            'interpolation': self.interpolation}
+
+        return config
+
+
+class DownBlock(Model):
+
+    def __init__(self,
+                 filters,
+                 kernel_size=(3, 3),
+                 normalization=True,
+                 activation="relu",
+                 pool_size=(2, 2),
+                 **kwargs):
+        super(DownBlock, self).__init__(**kwargs)
+
+        # initialize parameters
+        self.filters = filters
+        self.kernel_size = kernel_size
+        self.normalization = normalization
+        self.activation = activation
+        self.pool_size = pool_size
+
+        # define layers
+        self.conv_1 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+        self.conv_2 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+        self.residual = SameConv(
+            filters=self.filters,
+            kernel_size=(1, 1),
+            normalization=self.normalization,
+            activation=self.activation)
+        self.add = Add()
+        self.conv_3 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+        self.conv_4 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+        self.pool = MaxPooling2D(
+            pool_size=self.pool_size)
+
+    def call(self, inputs, training=False, mask=None):
+        # two convolution layers
+        x = self.conv_1(inputs)
+        x = self.conv_2(x)
+
+        # residual connection
+        residual = self.residual(inputs)
+        residual = self.add([x, residual])
+
+        # two convolution layers
+        x = self.conv_3(residual)
+        x = self.conv_4(x)
+
+        # pooling layer
+        x = self.pool(x)
+
+        return x, residual
+
+    def get_config(self):
+        config = {
+            'filters': self.filters,
+            'kernel_size': self.kernel_size,
+            'normalization': self.normalization,
+            'activation': self.activation,
+            'pool_size': self.pool_size}
+
+        return config
+
+
+class UpBlock(Model):
+
+    def __init__(self,
+                 filters,
+                 kernel_size=(3, 3),
+                 normalization=True,
+                 activation="relu",
+                 interpolation="bilinear",
+                 **kwargs):
+        super(UpBlock, self).__init__(**kwargs)
+
+        # initialize parameters
+        self.filters = filters
+        self.kernel_size = kernel_size
+        self.normalization = normalization
+        self.activation = activation
+        self.interpolation = interpolation
+
+        # define layers
+        self.upsampling = UpConv(
+            self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation,
+            interpolation=self.interpolation)
+        self.add_1 = Add()
+        self.conv_1 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+        self.conv_2 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+        self.add_2 = Add()
+        self.conv_3 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+        self.conv_4 = SameConv(
+            filters=self.filters,
+            kernel_size=self.kernel_size,
+            normalization=self.normalization,
+            activation=self.activation)
+
+    def call(self, inputs, training=False, mask=None):
+        # get inputs
+        x, residual = inputs
+
+        # upsampling
+        x = self.upsampling(x)
+
+        # residual connection
+        residual = self.add_1([x, residual])
+
+        # two convolution layers
+        x = self.conv_1(residual)
+        x = self.conv_2(x)
+
+        # residual connection
+        x = self.add_2([x, residual])
+
+        # two convolution layers
+        x = self.conv_3(x)
+        x = self.conv_4(x)
+
+        return x
+
+    def get_config(self):
+        config = {
+            'filters': self.filters,
+            'kernel_size': self.kernel_size,
+            'normalization': self.normalization,
+            'activation': self.activation,
+            'interpolation': self.interpolation}
+
+        return config
+
+
+class Encoder(Model):
+
+    def __init__(self,
+                 **kwargs):
+        super(Encoder, self).__init__(**kwargs)
+
+        # define layers
+        self.down_block_1 = DownBlock(filters=32)
+        self.down_block_2 = DownBlock(filters=64)
+        self.down_block_3 = DownBlock(filters=128)
+        self.down_block_4 = DownBlock(filters=256)
+        self.conv_1 = SameConv(filters=256)
+        self.conv_2 = SameConv(filters=256)
+
+    def call(self, inputs, training=False, mask=None):
+        # (batch, H/2, W/2, 32), (batch, H, W, 32)
+        x, residual_1 = self.down_block_1(inputs)
+
+        # (batch, H/4, W/4, 64), (batch, H/2, W/2, 64)
+        x, residual_2 = self.down_block_2(x)
+
+        # (batch, H/8, W/8, 128), (batch, H/4, W/4, 128)
+        x, residual_3 = self.down_block_3(x)
+
+        # (batch, H/16, W/16, 256), (batch, H/8, W/8, 256)
+        x, residual_4 = self.down_block_4(x)
+
+        # bottom
+        x = self.conv_1(x)
+        x = self.conv_2(x)  # (batch, H/16, W/16, 256)
+
+        return x, residual_1, residual_2, residual_3, residual_4
+
+    def get_config(self):
+        config = dict()
+
+        return config
+
+
+class Decoder(Model):
+
+    def __init__(self,
+                 **kwargs):
+        super(Decoder, self).__init__(**kwargs)
+
+        # define layers
+        self.up_block_4 = UpBlock(filters=256)
+        self.up_block_3 = UpBlock(filters=128)
+        self.up_block_2 = UpBlock(filters=64)
+        self.up_block_1 = UpBlock(filters=32)
+
+    def call(self, inputs, training=False, mask=None):
+        # get inputs
+        x, residual_1, residual_2, residual_3, residual_4 = inputs
+
+        # expansions
+        x = self.up_block_4([x, residual_4])  # (batch, H/8, W/8, 256)
+        x = self.up_block_3([x, residual_3])  # (batch, H/4, W/4, 128)
+        x = self.up_block_2([x, residual_2])  # (batch, H/2, W/2, 64)
+        x = self.up_block_1([x, residual_1])  # (batch, H, W, 32)
+
+        return x
+
+    def get_config(self):
+        config = dict()
+
+        return config
+
+
+class EncoderDecoder(Model):
+
+    def __init__(self,
+                 **kwargs):
+        super(EncoderDecoder, self).__init__(**kwargs)
+
+        # initialize parameters
+        self.encoder = Encoder()
+        self.decoder = Decoder()
+
+    def call(self, inputs, training=False, mask=None):
+        # encode
+        (x, residual_1, residual_2,
+         residual_3, residual_4) = self.encoder(inputs)
+
+        # decode
+        x = self.decoder([x, residual_1, residual_2, residual_3, residual_4])
+
+        return x
+
+    def get_config(self):
+        config = dict()
+
+        return config
diff --git a/bigfish/detection/__init__.py b/bigfish/detection/__init__.py
index 32661e21..f627a709 100644
--- a/bigfish/detection/__init__.py
+++ b/bigfish/detection/__init__.py
@@ -7,43 +7,54 @@
 and 3-d.
 """
 
-from .utils import get_sigma
-from .utils import get_radius
-
 from .spot_detection import detect_spots
 from .spot_detection import local_maximum_detection
 from .spot_detection import spots_thresholding
 from .spot_detection import automated_threshold_setting
+from .spot_detection import get_elbow_values
+
+from .dense_decomposition import decompose_dense
+from .dense_decomposition import get_dense_region
+from .dense_decomposition import simulate_gaussian_mixture
 
-from .cluster_decomposition import decompose_cluster
-from .cluster_decomposition import build_reference_spot
-from .cluster_decomposition import modelize_spot
-from .cluster_decomposition import precompute_erf
-from .cluster_decomposition import get_clustered_region
-from .cluster_decomposition import fit_gaussian_mixture
+from .spot_modeling import fit_subpixel
+from .spot_modeling import build_reference_spot
+from .spot_modeling import modelize_spot
+from .spot_modeling import precompute_erf
+from .spot_modeling import initialize_grid
+from .spot_modeling import gaussian_2d
+from .spot_modeling import gaussian_3d
 
-from .foci_detection import detect_foci
+from .cluster_detection import detect_clusters
 
+from .snr import compute_snr_spots
 
-_utils = [
-    "get_sigma",
-    "get_radius"]
 
 _spots = [
     "detect_spots",
     "local_maximum_detection",
     "spots_thresholding",
-    "automated_threshold_setting"]
+    "automated_threshold_setting",
+    "get_elbow_values"]
 
-_clusters = [
-    "decompose_cluster",
+_dense = [
+    "decompose_dense",
+    "get_dense_region",
+    "simulate_gaussian_mixture"]
+
+_model = [
+    "fit_subpixel",
     "build_reference_spot",
     "modelize_spot",
     "precompute_erf",
-    "get_clustered_region",
-    "fit_gaussian_mixture"]
+    "initialize_grid",
+    "gaussian_2d",
+    "gaussian_3d"]
+
+_clusters = [
+    "detect_clusters"]
 
-_foci = [
-    "detect_foci"]
+_snr = [
+    "compute_snr_spots"]
 
-__all__ = _utils + _spots + _clusters + _foci
+__all__ = _spots + _dense + _model + _clusters + _snr
diff --git a/bigfish/detection/foci_detection.py b/bigfish/detection/cluster_detection.py
similarity index 67%
rename from bigfish/detection/foci_detection.py
rename to bigfish/detection/cluster_detection.py
index 2a06115d..bdaba4c4 100644
--- a/bigfish/detection/foci_detection.py
+++ b/bigfish/detection/cluster_detection.py
@@ -3,7 +3,8 @@
 # License: BSD 3 clause
 
 """
-Functions to detected aggregated spots and foci.
+Function to cluster spots in point cloud and detect relevant aggregated
+structures.
 """
 
 import numpy as np
@@ -11,15 +12,15 @@
 from sklearn.cluster import DBSCAN
 
 
-# ### Detect foci ###
+# ### Detect clusters ###
 
-def detect_foci(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
-                nb_min_spots=4):
-    """Detect clustered spots we can define as foci.
+def detect_clusters(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
+                    nb_min_spots=4):
+    """Cluster spots and detect relevant aggregated structures.
 
-    1) If two spots are distant within a specific radius, we consider they are
-    related to each other.
-    2) A minimum number spots related to each others defines a foci.
+    #. If two spots are distant within a specific radius, we consider they are
+       related to each other.
+    #. A minimum number spots related to each others defines a cluster.
 
     Parameters
     ----------
@@ -46,10 +47,10 @@ def detect_foci(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
         (nb_spots, 3). One coordinate per dimension (zyx or yx coordinates)
         plus the index of the cluster assigned to the spot. If no cluster was
         assigned, value is -1.
-    foci : np.ndarray, np.int64
-        Array with shape (nb_foci, 5) or (nb_foci, 4). One coordinate per
-        dimension for the foci centroid (zyx or yx coordinates), the number of
-        spots detected in the foci and its index.
+    clusters : np.ndarray, np.int64
+        Array with shape (nb_clusters, 5) or (nb_clusters, 4). One coordinate
+        per dimension for the clusters centroid (zyx or yx coordinates), the
+        number of spots detected in the clusters and its index.
 
     """
     # check parameters
@@ -73,17 +74,17 @@ def detect_foci(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
     # case where no spot were detected
     if spots.size == 0:
         clustered_spots = np.array([], dtype=np.int64).reshape((0, ndim + 1))
-        foci = np.array([], dtype=np.int64).reshape((0, ndim + 2))
-        return clustered_spots, foci
+        clusters = np.array([], dtype=np.int64).reshape((0, ndim + 2))
+        return clustered_spots, clusters
 
     # cluster spots
     clustered_spots = _cluster_spots(
         spots, voxel_size_z, voxel_size_yx, radius, nb_min_spots)
 
-    # extract and shape foci information
-    foci = _extract_information(clustered_spots)
+    # extract and shape clusters information
+    clusters = _extract_information(clustered_spots)
 
-    return clustered_spots, foci
+    return clustered_spots, clusters
 
 
 def _convert_spot_coordinates(spots, voxel_size_z, voxel_size_yx):
@@ -166,7 +167,7 @@ def _cluster_spots(spots, voxel_size_z, voxel_size_yx, radius, nb_min_spots):
 
 
 def _extract_information(clustered_spots):
-    """Extract foci information from clustered spots.
+    """Extract clusters information from clustered spots.
 
     Parameters
     ----------
@@ -178,48 +179,51 @@ def _extract_information(clustered_spots):
 
     Returns
     -------
-    foci : np.ndarray, np.int64
-        Array with shape (nb_foci, 5) or (nb_foci, 4). One coordinate per
-        dimension for the foci centroid (zyx or yx coordinates), the number of
-        spots detected in the foci and its index.
+    clusters : np.ndarray, np.int64
+        Array with shape (nb_clusters, 5) or (nb_clusters, 4). One coordinate
+        per dimension for the cluster centroid (zyx or yx coordinates), the
+        number of spots detected in the cluster and its index.
 
     """
-    # extract information for 3-d foci...
+    # extract information for 3-d cluster...
     if clustered_spots.shape[1] == 4:
 
-        # get 3-d foci labels
-        labels_foci = np.unique(
+        # get 3-d cluster labels
+        labels_clusters = np.unique(
             clustered_spots[clustered_spots[:, 3] != -1, 3])
-        if labels_foci.size == 0:
-            foci = np.array([], dtype=np.int64).reshape((0, 5))
-            return foci
+        if labels_clusters.size == 0:
+            clusters = np.array([], dtype=np.int64).reshape((0, 5))
+            return clusters
 
         # shape information
-        foci = []
-        for label in labels_foci:
-            spots_in_foci = clustered_spots[clustered_spots[:, 3] == label, :3]
-            z_foci, y_foci, x_foci = spots_in_foci.mean(axis=0)
-            nb_spots_foci = len(spots_in_foci)
-            foci.append([z_foci, y_foci, x_foci, nb_spots_foci, label])
-        foci = np.array(foci, dtype=np.int64)
-
-    # ... and 2-d foci
+        clusters = []
+        for label in labels_clusters:
+            spots_in_cluster = clustered_spots[clustered_spots[:, 3] == label,
+                                               :3]
+            z_cluster, y_cluster, x_cluster = spots_in_cluster.mean(axis=0)
+            nb_spots_cluster = len(spots_in_cluster)
+            clusters.append([z_cluster, y_cluster, x_cluster,
+                             nb_spots_cluster, label])
+        clusters = np.array(clusters, dtype=np.int64)
+
+    # ... and 2-d cluster
     else:
 
-        # get 2-d foci labels
-        labels_foci = np.unique(
+        # get 2-d cluster labels
+        labels_clusters = np.unique(
             clustered_spots[clustered_spots[:, 2] != -1, 2])
-        if labels_foci.size == 0:
-            foci = np.array([], dtype=np.int64).reshape((0, 4))
-            return foci
+        if labels_clusters.size == 0:
+            clusters = np.array([], dtype=np.int64).reshape((0, 4))
+            return clusters
 
         # shape information
-        foci = []
-        for label in labels_foci:
-            spots_in_foci = clustered_spots[clustered_spots[:, 2] == label, :2]
-            y_foci, x_foci = spots_in_foci.mean(axis=0)
-            nb_spots_foci = len(spots_in_foci)
-            foci.append([y_foci, x_foci, nb_spots_foci, label])
-        foci = np.array(foci, dtype=np.int64)
-
-    return foci
+        clusters = []
+        for label in labels_clusters:
+            spots_in_cluster = clustered_spots[clustered_spots[:, 2] == label,
+                                               :2]
+            y_cluster, x_cluster = spots_in_cluster.mean(axis=0)
+            nb_spots_cluster = len(spots_in_cluster)
+            clusters.append([y_cluster, x_cluster, nb_spots_cluster, label])
+        clusters = np.array(clusters, dtype=np.int64)
+
+    return clusters
diff --git a/bigfish/detection/dense_decomposition.py b/bigfish/detection/dense_decomposition.py
new file mode 100644
index 00000000..ad3da6d3
--- /dev/null
+++ b/bigfish/detection/dense_decomposition.py
@@ -0,0 +1,797 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Functions to detect dense and bright regions (with potential clustered spots),
+then use gaussian functions to correct a misdetection in these regions.
+"""
+
+import warnings
+
+import numpy as np
+
+import bigfish.stack as stack
+from .spot_modeling import build_reference_spot, modelize_spot, precompute_erf
+from .spot_modeling import gaussian_2d, _initialize_grid_2d
+from .spot_modeling import gaussian_3d, _initialize_grid_3d
+
+from skimage.measure import regionprops
+from skimage.measure import label
+
+
+# ### Main function ###
+
+def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
+                    psf_z=None, psf_yx=200, alpha=0.5, beta=1, gamma=5):
+    """Detect dense and bright regions with potential clustered spots and
+    simulate a more realistic number of spots in these regions.
+
+    #. We estimate image background with a large gaussian filter. We then
+       remove the background from the original image to denoise it.
+    #. We build a reference spot by aggregating predetected spots.
+    #. We fit gaussian parameters on the reference spots.
+    #. We detect dense regions to decompose.
+    #. We simulate as many gaussians as possible in the candidate regions.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x) or (y, x).
+    spots : np.ndarray, np.int64
+        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2)
+        for 3-d or 2-d images respectively.
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, image is
+        considered in 2-d.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, image is considered in 2-d.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
+    alpha : int or float
+        Intensity percentile used to compute the reference spot, between 0
+        and 1. The higher, the brighter are the spots simulated in the dense
+        regions. Consequently, a high intensity score reduces the number of
+        spots added. Default is 0.5, meaning the reference spot considered is
+        the median spot.
+    beta : int or float
+        Multiplicative factor for the intensity threshold of a dense region.
+        Default is 1. Threshold is computed with the formula:
+
+        .. math::
+            \\mbox{threshold} = \\beta * \\mbox{max(median spot)}
+
+        With :math:`\\mbox{median spot}` the median value of all detected spot
+        signals.
+    gamma : int or float
+        Multiplicative factor use to compute a gaussian scale:
+
+        .. math::
+            \\mbox{scale} = \\frac{\\gamma * \\mbox{PSF}}{\\mbox{voxel size}}
+
+        We perform a large gaussian filter with such scale to estimate image
+        background and remove it from original image. A large gamma increases
+        the scale of the gaussian filter and smooth the estimated background.
+        To decompose very large bright areas, a larger gamma should be set.
+        If 0, image is not denoised.
+
+    Returns
+    -------
+    spots : np.ndarray, np.int64
+        Coordinate of the spots detected, with shape (nb_spots, 3) or
+        (nb_spots, 2). One coordinate per dimension (zyx or yx coordinates).
+    dense_regions : np.ndarray, np.int64
+        Array with shape (nb_regions, 7) or (nb_regions, 6). One coordinate
+        per dimension for the region centroid (zyx or yx coordinates), the
+        number of RNAs detected in the region, the area of the region, its
+        average intensity value and its index.
+    reference_spot : np.ndarray
+        Reference spot in 3-d or 2-d.
+
+    """
+    # check parameters
+    stack.check_array(image,
+                      ndim=[2, 3],
+                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_array(spots, ndim=2, dtype=np.int64)
+    stack.check_parameter(voxel_size_z=(int, float, type(None)),
+                          voxel_size_yx=(int, float),
+                          psf_z=(int, float, type(None)),
+                          psf_yx=(int, float),
+                          alpha=(int, float),
+                          beta=(int, float),
+                          gamma=(int, float))
+    if alpha < 0 or alpha > 1:
+        raise ValueError("'alpha' should be a value between 0 and 1, not {0}"
+                         .format(alpha))
+    if beta < 0:
+        raise ValueError("'beta' should be a positive value, not {0}"
+                         .format(beta))
+    if gamma < 0:
+        raise ValueError("'gamma' should be a positive value, not {0}"
+                         .format(gamma))
+
+    # check number of dimensions
+    ndim = image.ndim
+    if ndim == 3 and voxel_size_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'voxel_size_z' parameter is missing.".format(ndim))
+    if ndim == 3 and psf_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'psf_z' parameter is missing.".format(ndim))
+    if ndim != spots.shape[1]:
+        raise ValueError("Provided image has {0} dimensions but spots are "
+                         "detected in {1} dimensions."
+                         .format(ndim, spots.shape[1]))
+    if ndim == 2:
+        voxel_size_z, psf_z = None, None
+
+    # case where no spot were detected
+    if spots.size == 0:
+        dense_regions = np.array([], dtype=np.int64).reshape((0, ndim + 4))
+        reference_spot = np.zeros((5,) * ndim, dtype=image.dtype)
+        return spots, dense_regions, reference_spot
+
+    # compute expected standard deviation of the spots
+    sigma = stack.get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
+    large_sigma = tuple([sigma_ * gamma for sigma_ in sigma])
+
+    # denoise the image
+    if gamma > 0:
+        image_denoised = stack.remove_background_gaussian(
+            image,
+            sigma=large_sigma)
+    else:
+        image_denoised = image.copy()
+
+    # build a reference median spot
+    reference_spot = build_reference_spot(
+        image_denoised,
+        spots,
+        voxel_size_z, voxel_size_yx, psf_z, psf_yx,
+        alpha)
+
+    # case with an empty frame as reference spot
+    if reference_spot.sum() == 0:
+        dense_regions = np.array([], dtype=np.int64).reshape((0, ndim + 4))
+        return spots, dense_regions, reference_spot
+
+    # fit a gaussian function on the reference spot to be able to simulate it
+    parameters_fitted = modelize_spot(
+        reference_spot, voxel_size_z, voxel_size_yx, psf_z, psf_yx)
+    if ndim == 3:
+        sigma_z, sigma_yx, amplitude, background = parameters_fitted
+    else:
+        sigma_z = None
+        sigma_yx, amplitude, background = parameters_fitted
+
+    # use connected components to detect dense and bright regions
+    regions_to_decompose, spots_out_regions, region_size = get_dense_region(
+        image_denoised,
+        spots,
+        voxel_size_z, voxel_size_yx, psf_z, psf_yx,
+        beta)
+
+    # case where no region where detected
+    if regions_to_decompose.size == 0:
+        dense_regions = np.array([], dtype=np.int64).reshape((0, ndim + 4))
+        return spots, dense_regions, reference_spot
+
+    # precompute gaussian function values
+    max_grid = region_size + 1
+    precomputed_gaussian = precompute_erf(
+        voxel_size_z, voxel_size_yx, sigma_z, sigma_yx, max_grid=max_grid)
+
+    # simulate gaussian mixtures in the dense regions
+    spots_in_regions, dense_regions = simulate_gaussian_mixture(
+        image=image_denoised,
+        candidate_regions=regions_to_decompose,
+        voxel_size_z=voxel_size_z,
+        voxel_size_yx=voxel_size_yx,
+        sigma_z=sigma_z,
+        sigma_yx=sigma_yx,
+        amplitude=amplitude,
+        background=background,
+        precomputed_gaussian=precomputed_gaussian)
+
+    # normally the number of detected spots should increase
+    if len(spots_out_regions) + len(spots_in_regions) < len(spots):
+        warnings.warn("Problem occurs during the decomposition of dense "
+                      "regions. Less spots are detected after the "
+                      "decomposition than before.",
+                      UserWarning)
+
+    # merge outside and inside spots
+    spots = np.concatenate((spots_out_regions, spots_in_regions[:, :ndim]),
+                           axis=0)
+
+    return spots, dense_regions, reference_spot
+
+
+# ### Dense regions ###
+
+def get_dense_region(image, spots, voxel_size_z=None, voxel_size_yx=100,
+                     psf_z=None, psf_yx=200, beta=1):
+    """Detect and filter dense and bright regions.
+
+    A candidate region has at least 2 connected pixels above a specific
+    threshold.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x) or (y, x).
+    spots : np.ndarray, np.int64
+        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, we
+        consider a 2-d image.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, we consider a 2-d image.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
+    beta : int or float
+        Multiplicative factor for the intensity threshold of a dense region.
+        Default is 1. Threshold is computed with the formula:
+
+        .. math::
+            \\mbox{threshold} = \\beta * \\mbox{max(median spot)}
+
+        With :math:`\\mbox{median spot}` the median value of all detected spot
+        signals.
+
+    Returns
+    -------
+    dense_regions : np.ndarray
+        Array with selected ``skimage.measure._regionprops._RegionProperties``
+        objects.
+    spots_out_region : np.ndarray, np.int64
+        Coordinate of the spots detected out of dense regions, with shape
+        (nb_spots, 3) or (nb_spots, 2). One coordinate per dimension (zyx or
+        yx coordinates).
+    max_size : int
+        Maximum size of the regions.
+
+    """
+    # check parameters
+    stack.check_array(image,
+                      ndim=[2, 3],
+                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_array(spots, ndim=2, dtype=np.int64)
+    stack.check_parameter(voxel_size_z=(int, float, type(None)),
+                          voxel_size_yx=(int, float),
+                          psf_z=(int, float, type(None)),
+                          psf_yx=(int, float),
+                          beta=(int, float))
+    if beta < 0:
+        raise ValueError("'beta' should be a positive value, not {0}"
+                         .format(beta))
+
+    # check number of dimensions
+    ndim = image.ndim
+    if ndim == 3 and voxel_size_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'voxel_size_z' parameter is missing.".format(ndim))
+    if ndim == 3 and psf_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'psf_z' parameter is missing.".format(ndim))
+    if ndim != spots.shape[1]:
+        raise ValueError("Provided image has {0} dimensions but spots are "
+                         "detected in {1} dimensions."
+                         .format(ndim, spots.shape[1]))
+    if ndim == 2:
+        voxel_size_z, psf_z = None, None
+
+    # estimate median spot value and a threshold to detect dense regions
+    median_spot = build_reference_spot(
+        image,
+        spots,
+        voxel_size_z, voxel_size_yx, psf_z, psf_yx,
+        alpha=0.5)
+    threshold = int(median_spot.max() * beta)
+
+    # get connected regions
+    connected_regions = _get_connected_region(image, threshold)
+
+    # filter connected regions
+    (dense_regions, spots_out_region, max_size) = _filter_connected_region(
+        image, connected_regions, spots)
+
+    return dense_regions, spots_out_region, max_size
+
+
+def _get_connected_region(image, threshold):
+    """Find connected regions above a fixed threshold.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x) or (y, x).
+    threshold : int or float
+        A threshold to detect peaks.
+
+    Returns
+    -------
+    cc : np.ndarray, np.int64
+        Image labelled with shape (z, y, x) or (y, x).
+
+    """
+    # compute binary mask of the filtered image
+    mask = image > threshold
+
+    # find connected components
+    cc = label(mask)
+
+    return cc
+
+
+def _filter_connected_region(image, connected_component, spots):
+    """Filter dense and bright regions (defined as connected component
+    regions).
+
+    A candidate region has at least 2 connected pixels above a specific
+    threshold.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x) or (y, x).
+    connected_component : np.ndarray, np.int64
+        Image labelled with shape (z, y, x) or (y, x).
+    spots : np.ndarray, np.int64
+        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+
+    Returns
+    -------
+    regions_filtered : np.ndarray
+        Array with filtered skimage.measure._regionprops._RegionProperties.
+    spots_out_region : np.ndarray, np.int64
+        Coordinate of the spots outside the regions with shape (nb_spots, 3)
+        or (nb_spots, 2).
+    max_region_size : int
+        Maximum size of the regions.
+
+    """
+    # get properties of the different connected regions
+    regions = regionprops(connected_component, intensity_image=image)
+
+    # get different features of the regions
+    area = []
+    bbox = []
+    for i, region in enumerate(regions):
+        area.append(region.area)
+        bbox.append(region.bbox)
+    regions = np.array(regions)
+    area = np.array(area)
+    bbox = np.array(bbox)
+
+    # keep regions with a minimum size
+    big_area = area >= 2
+    regions_filtered = regions[big_area]
+    bbox_filtered = bbox[big_area]
+
+    # case where no region big enough were detected
+    if regions.size == 0:
+        regions_filtered = np.array([])
+        return regions_filtered, spots, 0
+
+    spots_out_region, max_region_size = _filter_spot_out_candidate_regions(
+        bbox_filtered, spots, nb_dim=image.ndim)
+
+    return regions_filtered, spots_out_region, max_region_size
+
+
+def _filter_spot_out_candidate_regions(candidate_bbox, spots, nb_dim):
+    """Filter spots out of the dense regions.
+
+    Parameters
+    ----------
+    candidate_bbox : List[Tuple]
+        List of Tuples with the bounding box coordinates.
+    spots : np.ndarray, np.int64
+        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+    nb_dim : int
+        Number of dimensions to consider (2 or 3).
+
+    Returns
+    -------
+    spots_out_region : np.ndarray, np.int64
+        Coordinate of the spots outside the regions with shape (nb_spots, 3)
+        or (nb_spots, 2).
+    max_region_size : int
+        Maximum size of the regions.
+
+    """
+    # initialization
+    mask_spots_out = np.ones(spots[:, 0].shape, dtype=bool)
+    max_region_size = 0
+
+    # get detected spots outside 3-d regions
+    if nb_dim == 3:
+        for box in candidate_bbox:
+            (min_z, min_y, min_x, max_z, max_y, max_x) = box
+
+            # get the size of the biggest region
+            size_z = max_z - min_z
+            size_y = max_y - min_y
+            size_x = max_x - min_x
+            max_region_size = max(max_region_size, size_z, size_y, size_x)
+
+            # get coordinates of spots inside the region
+            mask_spots_in = spots[:, 0] < max_z
+            mask_spots_in = (mask_spots_in & (spots[:, 1] < max_y))
+            mask_spots_in = (mask_spots_in & (spots[:, 2] < max_x))
+            mask_spots_in = (mask_spots_in & (min_z <= spots[:, 0]))
+            mask_spots_in = (mask_spots_in & (min_y <= spots[:, 1]))
+            mask_spots_in = (mask_spots_in & (min_x <= spots[:, 2]))
+            mask_spots_out = mask_spots_out & (~mask_spots_in)
+
+    # get detected spots outside 2-d regions
+    else:
+        for box in candidate_bbox:
+            (min_y, min_x, max_y, max_x) = box
+
+            # get the size of the biggest region
+            size_y = max_y - min_y
+            size_x = max_x - min_x
+            max_region_size = max(max_region_size, size_y, size_x)
+
+            # get coordinates of spots inside the region
+            mask_spots_in = spots[:, 0] < max_y
+            mask_spots_in = (mask_spots_in & (spots[:, 1] < max_x))
+            mask_spots_in = (mask_spots_in & (min_y <= spots[:, 0]))
+            mask_spots_in = (mask_spots_in & (min_x <= spots[:, 1]))
+            mask_spots_out = mask_spots_out & (~mask_spots_in)
+
+    # keep apart spots inside a region
+    spots_out_region = spots.copy()
+    spots_out_region = spots_out_region[mask_spots_out]
+
+    return spots_out_region, int(max_region_size)
+
+
+# ### Gaussian simulation ###
+
+def simulate_gaussian_mixture(image, candidate_regions, voxel_size_z=None,
+                              voxel_size_yx=100, sigma_z=None, sigma_yx=200,
+                              amplitude=100, background=0,
+                              precomputed_gaussian=None):
+    """Simulate as many gaussians as possible in the candidate dense regions in
+    order to get a more realistic number of spots.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x) or (y, x).
+    candidate_regions : np.ndarray
+        Array with filtered skimage.measure._regionprops._RegionProperties.
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, we consider
+        a 2-d image.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    sigma_z : int or float or None
+        Standard deviation of the gaussian along the z axis, in nanometer. If
+        None, we consider a 2-d image.
+    sigma_yx : int or float
+        Standard deviation of the gaussian along the yx axis, in nanometer.
+    amplitude : float
+        Amplitude of the gaussian.
+    background : float
+        Background minimum value of the image.
+    precomputed_gaussian : Tuple[np.ndarray]
+        Tuple with one tables of precomputed values for the erf, with shape
+        (nb_value, 2). One table per dimension.
+
+    Returns
+    -------
+    spots_in_regions : np.ndarray, np.int64
+        Coordinate of the spots detected inside dense regions, with shape
+        (nb_spots, 4) or (nb_spots, 3). One coordinate per dimension (zyx
+        or yx coordinates) plus the index of the region.
+    regions : np.ndarray, np.int64
+        Array with shape (nb_regions, 7) or (nb_regions, 6). One coordinate
+        per dimension for the region centroid (zyx or yx coordinates), the
+        number of RNAs detected in the region, the area of the region, its
+        average intensity value and its index.
+
+    """
+    # check parameters
+    stack.check_array(image,
+                      ndim=[2, 3],
+                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_parameter(candidate_regions=np.ndarray,
+                          voxel_size_z=(int, float, type(None)),
+                          voxel_size_yx=(int, float),
+                          sigma_z=(int, float, type(None)),
+                          sigma_yx=(int, float),
+                          amplitude=float,
+                          background=float)
+    if background < 0:
+        raise ValueError("Background value can't be negative: {0}"
+                         .format(background))
+
+    # check number of dimensions
+    ndim = image.ndim
+    if ndim == 3 and voxel_size_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'voxel_size_z' parameter is missing."
+                         .format(ndim))
+    if ndim == 3 and sigma_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'sigma_z' parameter is missing.".format(ndim))
+    if ndim == 2:
+        voxel_size_z, sigma_z = None, None
+
+    # simulate gaussian mixtures in the candidate regions...
+    spots_in_regions = []
+    regions = []
+
+    # ... for 3-d regions...
+    if image.ndim == 3:
+
+        for i_region, region in enumerate(candidate_regions):
+            image_region, _, coord_gaussian = _gaussian_mixture_3d(
+                image,
+                region,
+                voxel_size_z,
+                voxel_size_yx,
+                sigma_z,
+                sigma_yx,
+                amplitude,
+                background,
+                precomputed_gaussian)
+
+            # get coordinates of spots and regions in the original image
+            box = region.bbox
+            (min_z, min_y, min_x, _, _, _) = box
+            coord = np.array(coord_gaussian, dtype=np.float64)
+            coord[:, 0] = (coord[:, 0] / voxel_size_z) + min_z
+            coord[:, 1] = (coord[:, 1] / voxel_size_yx) + min_y
+            coord[:, 2] = (coord[:, 2] / voxel_size_yx) + min_x
+            spots_in_region = np.zeros((coord.shape[0], 4), dtype=np.int64)
+            spots_in_region[:, :3] = coord
+            spots_in_region[:, 3] = i_region
+            spots_in_regions.append(spots_in_region)
+            region_z, region_y, region_x = tuple(coord[0])
+            nb_rna_region = coord.shape[0]
+            region_area = region.area
+            region_intensity = region.mean_intensity
+            regions.append([region_z, region_y, region_x, nb_rna_region,
+                            region_area, region_intensity, i_region])
+
+    # ... or 2-d regions
+    else:
+
+        for i_region, region in enumerate(candidate_regions):
+            image_region, _, coord_gaussian = _gaussian_mixture_2d(
+                image,
+                region,
+                voxel_size_yx,
+                sigma_yx,
+                amplitude,
+                background,
+                precomputed_gaussian)
+
+            # get coordinates of spots and regions in the original image
+            box = region.bbox
+            (min_y, min_x, _, _) = box
+            coord = np.array(coord_gaussian, dtype=np.float64)
+            coord[:, 0] = (coord[:, 0] / voxel_size_yx) + min_y
+            coord[:, 1] = (coord[:, 1] / voxel_size_yx) + min_x
+            spots_in_region = np.zeros((coord.shape[0], 3), dtype=np.int64)
+            spots_in_region[:, :2] = coord
+            spots_in_region[:, 2] = i_region
+            spots_in_regions.append(spots_in_region)
+            region_y, region_x = tuple(coord[0])
+            nb_rna_region = coord.shape[0]
+            region_area = region.area
+            region_intensity = region.mean_intensity
+            regions.append([region_y, region_x, nb_rna_region,
+                            region_area, region_intensity, i_region])
+
+    spots_in_regions = np.concatenate(spots_in_regions, axis=0)
+    regions = np.array(regions, dtype=np.int64)
+
+    return spots_in_regions, regions
+
+
+def _gaussian_mixture_3d(image, region, voxel_size_z, voxel_size_yx, sigma_z,
+                         sigma_yx, amplitude, background, precomputed_gaussian,
+                         limit_gaussian=1000):
+    """Fit as many 3-d gaussians as possible in a candidate region.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.uint
+        A 3-d image with detected spot and shape (z, y, x).
+    region : skimage.measure._regionprops._RegionProperties
+        Properties of a candidate region.
+    voxel_size_z : int or float
+        Height of a voxel, along the z axis, in nanometer.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    sigma_z : int or float
+        Standard deviation of the gaussian along the z axis, in pixel.
+    sigma_yx : int or float
+        Standard deviation of the gaussian along the yx axis, in pixel.
+    amplitude : float
+        Amplitude of the gaussian.
+    background : float
+        Background minimum value of the image.
+    precomputed_gaussian : Tuple[np.ndarray]
+        Tuple with one tables of precomputed values for the erf, with shape
+        (nb_value, 2). One table per dimension.
+    limit_gaussian : int
+        Limit number of gaussian to fit into this region.
+
+    Returns
+    -------
+    image_region : np.ndarray, np.uint
+        A 3-d image with detected spots and shape (z, y, x).
+    best_simulation : np.ndarray, np.uint
+        A 3-d image with simulated spots and shape (z, y, x).
+    positions_gaussian : List[List]
+        List of positions (as a list [z, y, x]) for the different gaussian
+        simulations used in the mixture.
+
+    """
+    # get an image of the region
+    box = tuple(region.bbox)
+    image_region = image[box[0]:box[3], box[1]:box[4], box[2]:box[5]]
+    image_region_raw = np.reshape(image_region, image_region.size)
+    image_region_raw = image_region_raw.astype(np.float64)
+
+    # build a grid to represent this image
+    grid = _initialize_grid_3d(image_region, voxel_size_z, voxel_size_yx)
+
+    # add a gaussian for each local maximum while the RSS decreases
+    simulation = np.zeros_like(image_region_raw)
+    residual = image_region_raw - simulation
+    ssr = np.sum(residual ** 2)
+    diff_ssr = -1
+    nb_gaussian = 0
+    best_simulation = simulation.copy()
+    positions_gaussian = []
+    while diff_ssr < 0 or nb_gaussian == limit_gaussian:
+        position_gaussian = np.argmax(residual)
+        positions_gaussian.append(list(grid[:, position_gaussian]))
+        simulation += gaussian_3d(grid=grid,
+                                  mu_z=float(positions_gaussian[-1][0]),
+                                  mu_y=float(positions_gaussian[-1][1]),
+                                  mu_x=float(positions_gaussian[-1][2]),
+                                  sigma_z=sigma_z,
+                                  sigma_yx=sigma_yx,
+                                  voxel_size_z=voxel_size_z,
+                                  voxel_size_yx=voxel_size_yx,
+                                  psf_amplitude=amplitude,
+                                  psf_background=background,
+                                  precomputed=precomputed_gaussian)
+        residual = image_region_raw - simulation
+        new_ssr = np.sum(residual ** 2)
+        diff_ssr = new_ssr - ssr
+        ssr = new_ssr
+        nb_gaussian += 1
+        background = 0
+
+        if diff_ssr < 0:
+            best_simulation = simulation.copy()
+
+    if 1 < nb_gaussian < limit_gaussian:
+        positions_gaussian.pop(-1)
+    elif nb_gaussian == limit_gaussian:
+        warnings.warn("Problem occurs during the decomposition of a dense "
+                      "region. More than {0} spots seem to be necessary to "
+                      "reproduce the candidate region and decomposition was "
+                      "stopped early. Set a higher limit or check a potential "
+                      "artifact in the image if you do not expect such a "
+                      "large region to be decomposed.".format(limit_gaussian),
+                      UserWarning)
+
+    best_simulation = np.reshape(best_simulation, image_region.shape)
+    max_value_dtype = np.iinfo(image_region.dtype).max
+    best_simulation = np.clip(best_simulation, 0, max_value_dtype)
+    best_simulation = best_simulation.astype(image_region.dtype)
+
+    return image_region, best_simulation, positions_gaussian
+
+
+def _gaussian_mixture_2d(image, region, voxel_size_yx, sigma_yx, amplitude,
+                         background, precomputed_gaussian,
+                         limit_gaussian=1000):
+    """Fit as many 2-d gaussians as possible in a candidate region.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.uint
+        A 2-d image with detected spot and shape (y, x).
+    region : skimage.measure._regionprops._RegionProperties
+        Properties of a candidate region.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    sigma_yx : int or float
+        Standard deviation of the gaussian along the yx axis, in pixel.
+    amplitude : float
+        Amplitude of the gaussian.
+    background : float
+        Background minimum value of the image.
+    precomputed_gaussian : Tuple[np.ndarray]
+        Tuple with one tables of precomputed values for the erf, with shape
+        (nb_value, 2). One table per dimension.
+    limit_gaussian : int
+        Limit number of gaussian to fit into this region.
+
+    Returns
+    -------
+    image_region : np.ndarray, np.uint
+        A 2-d image with detected spots and shape (y, x).
+    best_simulation : np.ndarray, np.uint
+        A 2-d image with simulated spots and shape (y, x).
+    positions_gaussian : List[List]
+        List of positions (as a list [y, x]) for the different gaussian
+        simulations used in the mixture.
+
+    """
+    # get an image of the region
+    box = tuple(region.bbox)
+    image_region = image[box[0]:box[2], box[1]:box[3]]
+    image_region_raw = np.reshape(image_region, image_region.size)
+    image_region_raw = image_region_raw.astype(np.float64)
+
+    # build a grid to represent this image
+    grid = _initialize_grid_2d(image_region, voxel_size_yx)
+
+    # add a gaussian for each local maximum while the RSS decreases
+    simulation = np.zeros_like(image_region_raw)
+    residual = image_region_raw - simulation
+    ssr = np.sum(residual ** 2)
+    diff_ssr = -1
+    nb_gaussian = 0
+    best_simulation = simulation.copy()
+    positions_gaussian = []
+    while diff_ssr < 0 or nb_gaussian == limit_gaussian:
+        position_gaussian = np.argmax(residual)
+        positions_gaussian.append(list(grid[:, position_gaussian]))
+        simulation += gaussian_2d(grid=grid,
+                                  mu_y=float(positions_gaussian[-1][0]),
+                                  mu_x=float(positions_gaussian[-1][1]),
+                                  sigma_yx=sigma_yx,
+                                  voxel_size_yx=voxel_size_yx,
+                                  psf_amplitude=amplitude,
+                                  psf_background=background,
+                                  precomputed=precomputed_gaussian)
+        residual = image_region_raw - simulation
+        new_ssr = np.sum(residual ** 2)
+        diff_ssr = new_ssr - ssr
+        ssr = new_ssr
+        nb_gaussian += 1
+        background = 0
+
+        if diff_ssr < 0:
+            best_simulation = simulation.copy()
+
+    if 1 < nb_gaussian < limit_gaussian:
+        positions_gaussian.pop(-1)
+    elif nb_gaussian == limit_gaussian:
+        warnings.warn("Problem occurs during the decomposition of a dense "
+                      "region. More than {0} spots seem to be necessary to "
+                      "reproduce the candidate region and decomposition was "
+                      "stopped early. Set a higher limit or check a potential "
+                      "artifact in the image if you do not expect such a "
+                      "large region to be decomposed.".format(limit_gaussian),
+                      UserWarning)
+
+    best_simulation = np.reshape(best_simulation, image_region.shape)
+    max_value_dtype = np.iinfo(image_region.dtype).max
+    best_simulation = np.clip(best_simulation, 0, max_value_dtype)
+    best_simulation = best_simulation.astype(image_region.dtype)
+
+    return image_region, best_simulation, positions_gaussian
diff --git a/bigfish/detection/snr.py b/bigfish/detection/snr.py
new file mode 100644
index 00000000..7eb37c50
--- /dev/null
+++ b/bigfish/detection/snr.py
@@ -0,0 +1,172 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Functions to compute signal-to-noise ratio from detected spots.
+"""
+
+import numpy as np
+
+import bigfish.stack as stack
+from .spot_modeling import _get_spot_volume
+from .spot_modeling import _get_spot_surface
+
+
+# ### SNR ###
+
+def compute_snr_spots(image, spots, voxel_size_z=None, voxel_size_yx=100,
+                      psf_z=None, psf_yx=200):
+    """Compute signal-to-noise ratio (SNR) based on spot coordinates.
+
+    .. math::
+
+        \\mbox{SNR} = \\frac{\\mbox{max(spot signal)} -
+        \\mbox{mean(background)}}{\\mbox{std(background)}}
+
+    Background is a region twice larger surrounding the spot region. Only the
+    y and x dimensions are taking into account to compute the SNR.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x) or (y, x).
+    spots : np.ndarray, np.int64 or np.float64
+        Coordinate of the spots, with shape (nb_spots, 3) or (nb_spots, 2).
+        One coordinate per dimension (zyx or yx coordinates).
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, we consider
+        a 2-d spot.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, we consider a 2-d spot.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
+
+    Returns
+    -------
+    snr : float
+        Median signal-to-noise ratio computed for every spots.
+
+    """
+    # check parameters
+    stack.check_parameter(voxel_size_z=(int, float, type(None)),
+                          voxel_size_yx=(int, float),
+                          psf_z=(int, float, type(None)),
+                          psf_yx=(int, float))
+    stack.check_array(image,
+                      ndim=[2, 3],
+                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_range_value(image, min_=0)
+    stack.check_array(spots, ndim=2, dtype=[np.float64, np.int64])
+
+    # check consistency between parameters
+    ndim = image.ndim
+    if ndim == 3 and voxel_size_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'voxel_size_z' parameter is missing.".format(ndim))
+    if ndim == 3 and psf_z is None:
+        raise ValueError("Provided image has {0} dimensions but "
+                         "'psf_z' parameter is missing.".format(ndim))
+    if ndim != spots.shape[1]:
+        raise ValueError("Provided image has {0} dimensions but 'spots' are "
+                         "detected in {1} dimensions."
+                         .format(ndim, spots.shape[1]))
+    if ndim == 2:
+        voxel_size_z, psf_z = None, None
+
+    # cast spots coordinates if needed
+    if spots.dtype == np.float64:
+        spots = np.round(spots).astype(np.int64)
+
+    # cast image if needed
+    image_to_process = image.copy().astype(np.float64)
+
+    # clip coordinate if needed
+    if ndim == 3:
+        spots[:, 0] = np.clip(spots[:, 0], 0, image_to_process.shape[0] - 1)
+        spots[:, 1] = np.clip(spots[:, 1], 0, image_to_process.shape[1] - 1)
+        spots[:, 2] = np.clip(spots[:, 2], 0, image_to_process.shape[2] - 1)
+    else:
+        spots[:, 0] = np.clip(spots[:, 0], 0, image_to_process.shape[0] - 1)
+        spots[:, 1] = np.clip(spots[:, 1], 0, image_to_process.shape[1] - 1)
+
+    # compute spot radius
+    radius_signal_ = stack.get_radius(voxel_size_z=voxel_size_z,
+                                      voxel_size_yx=voxel_size_yx,
+                                      psf_z=psf_z, psf_yx=psf_yx)
+
+    # compute the neighbourhood radius
+    radius_background_ = tuple(i * 2 for i in radius_signal_)
+
+    # ceil radii
+    radius_signal = np.ceil(radius_signal_).astype(np.int)
+    radius_background = np.ceil(radius_background_).astype(np.int)
+
+    # loop over spots
+    snr_spots = []
+    for spot in spots:
+
+        # extract spot images
+        spot_y = spot[ndim - 2]
+        spot_x = spot[ndim - 1]
+        radius_signal_yx = radius_signal[-1]
+        radius_background_yx = radius_background[-1]
+        edge_background_yx = radius_background_yx - radius_signal_yx
+        if ndim == 3:
+            spot_z = spot[0]
+            radius_background_z = radius_background[0]
+            max_signal = image_to_process[spot_z, spot_y, spot_x]
+            spot_background_, _ = _get_spot_volume(
+                image_to_process, spot_z, spot_y, spot_x,
+                radius_background_z, radius_background_yx)
+            spot_background = spot_background_.copy()
+
+            # discard spot if cropped at the border (along y and x dimensions)
+            expected_size = (2 * radius_background_yx + 1) ** 2
+            actual_size = spot_background.shape[1] * spot_background.shape[2]
+            if expected_size != actual_size:
+                continue
+
+            # remove signal from background crop
+            spot_background[:,
+                            edge_background_yx:-edge_background_yx,
+                            edge_background_yx:-edge_background_yx] = -1
+            spot_background = spot_background[spot_background >= 0]
+
+        else:
+            max_signal = image_to_process[spot_y, spot_x]
+            spot_background_, _ = _get_spot_surface(
+                image_to_process, spot_y, spot_x, radius_background_yx)
+            spot_background = spot_background_.copy()
+
+            # discard spot if cropped at the border
+            expected_size = (2 * radius_background_yx + 1) ** 2
+            if expected_size != spot_background.size:
+                continue
+
+            # remove signal from background crop
+            spot_background[edge_background_yx:-edge_background_yx,
+                            edge_background_yx:-edge_background_yx] = -1
+            spot_background = spot_background[spot_background >= 0]
+
+        # compute mean background
+        mean_background = np.mean(spot_background)
+
+        # compute standard deviation background
+        std_background = np.std(spot_background)
+
+        # compute SNR
+        snr = (max_signal - mean_background) / std_background
+        snr_spots.append(snr)
+
+    #  average SNR
+    if len(snr_spots) == 0:
+        snr = 0.
+    else:
+        snr = np.median(snr_spots)
+
+    return snr
diff --git a/bigfish/detection/spot_detection.py b/bigfish/detection/spot_detection.py
index 229d906d..9ab4c395 100644
--- a/bigfish/detection/spot_detection.py
+++ b/bigfish/detection/spot_detection.py
@@ -3,14 +3,14 @@
 # License: BSD 3 clause
 
 """
-Class and functions to detect RNA spots in 2-d and 3-d.
+Functions to detect spots in 2-d and 3-d.
 """
 
+import warnings
 import scipy.ndimage as ndi
 import numpy as np
 
 import bigfish.stack as stack
-from .utils import get_sigma
 
 from skimage.measure import regionprops
 from skimage.measure import label
@@ -24,12 +24,12 @@ def detect_spots(images, threshold=None, remove_duplicate=True,
     """Apply LoG filter followed by a Local Maximum algorithm to detect spots
     in a 2-d or 3-d image.
 
-    1) We smooth the image with a LoG filter.
-    2) We apply a multidimensional maximum filter.
-    3) A pixel which has the same value in the original and filtered images
-    is a local maximum.
-    4) We remove local peaks under a threshold.
-    5) We keep only one pixel coordinate per detected spot.
+    #. We smooth the image with a LoG filter.
+    #. We apply a multidimensional maximum filter.
+    #. A pixel which has the same value in the original and filtered images
+       is a local maximum.
+    #. We remove local peaks under a threshold.
+    #. We keep only one pixel coordinate per detected spot.
 
     Parameters
     ----------
@@ -149,12 +149,12 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
     """Apply LoG filter followed by a Local Maximum algorithm to detect spots
     in a 2-d or 3-d image.
 
-    1) We smooth the image with a LoG filter.
-    2) We apply a multidimensional maximum filter.
-    3) A pixel which has the same value in the original and filtered images
-    is a local maximum.
-    4) We remove local peaks under a threshold.
-    5) We keep only one pixel coordinate per detected spot.
+    #. We smooth the image with a LoG filter.
+    #. We apply a multidimensional maximum filter.
+    #. A pixel which has the same value in the original and filtered images
+       is a local maximum.
+    #. We remove local peaks under a threshold.
+    #. We keep only one pixel coordinate per detected spot.
 
     Parameters
     ----------
@@ -176,11 +176,11 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
     voxel_size_yx : int or float
         Size of a voxel on the yx plan, in nanometer.
     psf_z : int or float or None
-        Theoretical size of the PSF emitted by a spot in the z plan,
-        in nanometer. If None, image is considered in 2-d.
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, image is considered in 2-d.
     psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
 
     Returns
     -------
@@ -192,7 +192,7 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
 
     """
     # initialization
-    sigma = get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
+    sigma = stack.get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
     n = len(images)
 
     # apply LoG filter and find local maximum
@@ -233,7 +233,8 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
         thresholds, count_spots = _get_spot_counts(thresholds, all_value_spots)
 
         # select threshold where the kink of the distribution is located
-        threshold, _, _ = _get_breaking_point(thresholds, count_spots)
+        if count_spots.size > 0:
+            threshold, _, _ = _get_breaking_point(thresholds, count_spots)
 
     # detect spots
     all_spots = []
@@ -260,22 +261,25 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
 def local_maximum_detection(image, min_distance):
     """Compute a mask to keep only local maximum, in 2-d and 3-d.
 
-    1) We apply a multidimensional maximum filter.
-    2) A pixel which has the same value in the original and filtered images
-    is a local maximum.
+    #. We apply a multidimensional maximum filter.
+    #. A pixel which has the same value in the original and filtered images
+       is a local maximum.
 
     Several connected pixels can have the same value. In such a case, the
     local maximum is not unique.
 
+    In order to make the detection robust, it should be applied to a
+    filtered image (using :func:`bigfish.stack.log_filter` for example).
+
     Parameters
     ----------
     image : np.ndarray
         Image to process with shape (z, y, x) or (y, x).
     min_distance : int, float or Tuple(float)
         Minimum distance (in pixels) between two spots we want to be able to
-        detect separately. One value per spatial dimension (zyx or
-        yx dimensions). If it's a scalar, the same distance is applied to
-        every dimensions.
+        detect separately. One value per spatial dimension (zyx or yx
+        dimensions). If it's a scalar, the same distance is applied to every
+        dimensions.
 
     Returns
     -------
@@ -316,10 +320,10 @@ def spots_thresholding(image, mask_local_max, threshold,
     """Filter detected spots and get coordinates of the remaining spots.
 
     In order to make the thresholding robust, it should be applied to a
-    filtered image (bigfish.stack.log_filter for example). If the local
-    maximum is not unique (it can happen with connected pixels with the same
-    value), connected component algorithm is applied to keep only one
-    coordinate per spot.
+    filtered image (using :func:`bigfish.stack.log_filter`
+    for example). If the local maximum is not unique (it can happen if connected
+    pixels have the same value), a connected component algorithm is applied to
+    keep only one coordinate per spot.
 
     Parameters
     ----------
@@ -327,8 +331,9 @@ def spots_thresholding(image, mask_local_max, threshold,
         Image with shape (z, y, x) or (y, x).
     mask_local_max : np.ndarray, bool
         Mask with shape (z, y, x) or (y, x) indicating the local peaks.
-    threshold : float or int
-        A threshold to discriminate relevant spots from noisy blobs.
+    threshold : float, int or None
+        A threshold to discriminate relevant spots from noisy blobs. If None,
+        detection is aborted with a warning.
     remove_duplicate : bool
         Remove potential duplicate coordinates for the same spots. Slow the
         running.
@@ -349,9 +354,17 @@ def spots_thresholding(image, mask_local_max, threshold,
     stack.check_array(mask_local_max,
                       ndim=[2, 3],
                       dtype=[bool])
-    stack.check_parameter(threshold=(float, int),
+    stack.check_parameter(threshold=(float, int, type(None)),
                           remove_duplicate=bool)
 
+    if threshold is None:
+        mask = np.zeros_like(image, dtype=bool)
+        spots = np.array([], dtype=np.int64).reshape((0, image.ndim))
+        warnings.warn("No spots were detected (threshold is {0})."
+                      .format(threshold),
+                      UserWarning)
+        return spots, mask
+
     # remove peak with a low intensity
     mask = (mask_local_max & (image > threshold))
     if mask.sum() == 0:
@@ -378,6 +391,12 @@ def spots_thresholding(image, mask_local_max, threshold,
         spots = np.nonzero(mask)
         spots = np.column_stack(spots)
 
+    # case where no spots were detected
+    if spots.size == 0:
+        warnings.warn("No spots were detected (threshold is {0})."
+                      .format(threshold),
+                      UserWarning)
+
     return spots, mask
 
 
@@ -387,10 +406,10 @@ def automated_threshold_setting(image, mask_local_max):
     """Automatically set the optimal threshold to detect spots.
 
     In order to make the thresholding robust, it should be applied to a
-    filtered image (bigfish.stack.log_filter for example). The optimal
-    threshold is selected based on the spots distribution. The latter should
-    have a kink discriminating a fast decreasing stage from a more stable one
-    (a plateau).
+    filtered image (using :func:`bigfish.stack.log_filter` for example). The
+    optimal threshold is selected based on the spots distribution. The latter
+    should have an elbow curve discriminating a fast decreasing stage from a
+    more stable one (a plateau).
 
     Parameters
     ----------
@@ -423,8 +442,13 @@ def automated_threshold_setting(image, mask_local_max):
     value_spots = image[mask_spots]
     thresholds, count_spots = _get_spot_counts(thresholds, value_spots)
 
-    # select threshold where the kink of the distribution is located
-    optimal_threshold, _, _ = _get_breaking_point(thresholds, count_spots)
+    # select threshold where the break of the distribution is located
+    if count_spots.size > 0:
+        optimal_threshold, _, _ = _get_breaking_point(thresholds, count_spots)
+
+    # case where no spots were detected
+    else:
+        optimal_threshold = None
 
     return optimal_threshold
 
@@ -467,6 +491,8 @@ def _get_spot_counts(thresholds, value_spots):
 
     Returns
     -------
+    thresholds : np.ndarray, np.float64
+        Candidate threshold values.
     count_spots : np.ndarray, np.float64
         Spots count function.
 
@@ -521,3 +547,124 @@ def _get_breaking_point(x, y):
     breaking_point = float(x[i])
 
     return breaking_point, x, y
+
+
+def get_elbow_values(images, voxel_size_z=None, voxel_size_yx=100, psf_z=None,
+                     psf_yx=200):
+    """Get values to plot the elbow curve used to automatically set the
+    threshold.
+
+    Parameters
+    ----------
+    images : List[np.ndarray] or np.ndarray
+        Image (or list of images) with shape (z, y, x) or (y, x). If several
+        images are provided, the same threshold is applied.
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, image is
+        considered in 2-d.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan,
+        in nanometer. If None, image is considered in 2-d.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan,
+        in nanometer.
+
+    Returns
+    -------
+    thresholds : np.ndarray, np.float64
+        Candidate threshold values.
+    count_spots : np.ndarray, np.float64
+        Spots count function.
+    threshold : float or None
+        Threshold automatically set.
+
+    """
+    # check parameters
+    stack.check_parameter(voxel_size_z=(int, float, type(None)),
+                          voxel_size_yx=(int, float),
+                          psf_z=(int, float, type(None)),
+                          psf_yx=(int, float))
+
+    # if one image is provided we enlist it
+    if not isinstance(images, list):
+        stack.check_array(images,
+                          ndim=[2, 3],
+                          dtype=[np.uint8, np.uint16,
+                                 np.float32, np.float64])
+        ndim = images.ndim
+        images = [images]
+        n = 1
+    else:
+        ndim = None
+        for i, image in enumerate(images):
+            stack.check_array(image,
+                              ndim=[2, 3],
+                              dtype=[np.uint8, np.uint16,
+                                     np.float32, np.float64])
+            if i == 0:
+                ndim = image.ndim
+            else:
+                if ndim != image.ndim:
+                    raise ValueError("Provided images should have the same "
+                                     "number of dimensions.")
+        n = len(images)
+
+    # check consistency between parameters
+    if ndim == 3 and voxel_size_z is None:
+        raise ValueError("Provided images has {0} dimensions but "
+                         "'voxel_size_z' parameter is missing.".format(ndim))
+    if ndim == 3 and psf_z is None:
+        raise ValueError("Provided images has {0} dimensions but "
+                         "'psf_z' parameter is missing.".format(ndim))
+    if ndim == 2:
+        voxel_size_z = None
+        psf_z = None
+
+    # compute sigma
+    sigma = stack.get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
+
+    # apply LoG filter and find local maximum
+    images_filtered = []
+    pixel_values = []
+    masks = []
+    for image in images:
+        # filter image
+        image_filtered = stack.log_filter(image, sigma)
+        images_filtered.append(image_filtered)
+
+        # get pixels value
+        pixel_values += list(image_filtered.ravel())
+
+        # find local maximum
+        mask_local_max = local_maximum_detection(
+            image_filtered, sigma)
+        masks.append(mask_local_max)
+
+    # get threshold values we want to test
+    thresholds = _get_candidate_thresholds(pixel_values)
+
+    # get spots count and its logarithm
+    all_value_spots = []
+    minimum_threshold = float(thresholds[0])
+    for i in range(n):
+        image_filtered = images_filtered[i]
+        mask_local_max = masks[i]
+        spots, mask_spots = spots_thresholding(
+            image_filtered, mask_local_max,
+            threshold=minimum_threshold,
+            remove_duplicate=False)
+        value_spots = image_filtered[mask_spots]
+        all_value_spots.append(value_spots)
+    all_value_spots = np.concatenate(all_value_spots)
+    thresholds, count_spots = _get_spot_counts(
+        thresholds, all_value_spots)
+
+    # select threshold where the kink of the distribution is located
+    if count_spots.size > 0:
+        threshold, _, _ = _get_breaking_point(thresholds, count_spots)
+    else:
+        threshold = None
+
+    return thresholds, count_spots, threshold
diff --git a/bigfish/detection/cluster_decomposition.py b/bigfish/detection/spot_modeling.py
similarity index 52%
rename from bigfish/detection/cluster_decomposition.py
rename to bigfish/detection/spot_modeling.py
index 59be3d00..542c6cf2 100644
--- a/bigfish/detection/cluster_decomposition.py
+++ b/bigfish/detection/spot_modeling.py
@@ -3,8 +3,7 @@
 # License: BSD 3 clause
 
 """
-Functions to fit gaussian functions to the detected RNA spots, especially in
-clustered regions.
+Functions to model spots by fitting gaussian parameters.
 """
 
 import warnings
@@ -12,181 +11,9 @@
 import numpy as np
 
 import bigfish.stack as stack
-from .utils import get_sigma, get_radius
 
 from scipy.special import erf
 from scipy.optimize import curve_fit
-from skimage.measure import regionprops
-from skimage.measure import label
-
-
-# ### Main function ###
-
-def decompose_cluster(image, spots, voxel_size_z=None, voxel_size_yx=100,
-                      psf_z=None, psf_yx=200, alpha=0.5, beta=1):
-    """Detect potential regions with clustered spots and fit as many reference
-    spots as possible in these regions.
-
-    1) We estimate image background with a large gaussian filter. We then
-    remove the background from the original image to denoise it.
-    2) We build a reference spot by aggregating predetected spots.
-    3) We fit a gaussian function on the reference spots.
-    4) We detect potential clustered regions to decompose.
-    5) We simulate as many gaussians as possible in the candidate regions.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image with shape (z, y, x) or (y, x).
-    spots : np.ndarray, np.int64
-        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2)
-        for 3-d or 2-d images respectively.
-    voxel_size_z : int or float or None
-        Height of a voxel, along the z axis, in nanometer. If None, image is
-        considered in 2-d.
-    # TODO Error returned with a float
-    voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
-    psf_z : int or float or None
-        Theoretical size of the PSF emitted by a spot in the z plan,
-        in nanometer. If None, image is considered in 2-d.
-    psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
-    alpha : int or float
-        Intensity score of the reference spot, between 0 and 1. The higher,
-        the brighter are the spots fitted in the clusters. Consequently, a high
-        intensity score reduces the number of spots per cluster. Default is
-        0.5.
-    beta : int or float
-        Multiplicative factor for the intensity threshold of a cluster region.
-        Default is 1. Threshold is computed with the formula :
-            threshold = beta * max(median_spot)
-
-    Returns
-    -------
-    spots : np.ndarray, np.int64
-        Coordinate of the spots detected, with shape (nb_spots, 3) or
-        (nb_spots, 2). One coordinate per dimension (zyx or yx coordinates).
-    clusters : np.ndarray, np.int64
-        Array with shape (nb_cluster, 7) or (nb_cluster, 6). One coordinate
-        per dimension for the cluster centroid (zyx or yx coordinates), the
-        number of RNAs detected in the cluster, the area of the cluster
-        region, its average intensity value and its index.
-    reference_spot : np.ndarray
-        Reference spot in 3-d or 2-d.
-
-    """
-    # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    stack.check_array(spots, ndim=2, dtype=np.int64)
-    stack.check_parameter(voxel_size_z=(int, float, type(None)),
-                          voxel_size_yx=(int, float),
-                          psf_z=(int, float, type(None)),
-                          psf_yx=(int, float),
-                          alpha=(int, float),
-                          beta=(int, float))
-    if alpha < 0 or alpha > 1:
-        raise ValueError("'alpha' should be a value between 0 and 1, not {0}"
-                         .format(alpha))
-    if beta < 0:
-        raise ValueError("'beta' should be a positive value, not {0}"
-                         .format(beta))
-
-    # check number of dimensions
-    ndim = image.ndim
-    if ndim == 3 and voxel_size_z is None:
-        raise ValueError("Provided image has {0} dimensions but "
-                         "'voxel_size_z' parameter is missing.".format(ndim))
-    if ndim == 3 and psf_z is None:
-        raise ValueError("Provided image has {0} dimensions but "
-                         "'psf_z' parameter is missing.".format(ndim))
-    if ndim != spots.shape[1]:
-        raise ValueError("Provided image has {0} dimensions but spots are "
-                         "detected in {1} dimensions."
-                         .format(ndim, spots.shape[1]))
-    if ndim == 2:
-        voxel_size_z, psf_z = None, None
-
-    # case where no spot were detected
-    if spots.size == 0:
-        cluster = np.array([], dtype=np.int64).reshape((0, ndim + 4))
-        reference_spot = np.zeros((5,) * ndim, dtype=image.dtype)
-        return spots, cluster, reference_spot
-
-    # compute expected standard deviation of the spots
-    sigma = get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
-    large_sigma = tuple([sigma_ * 5 for sigma_ in sigma])
-
-    # denoise the image
-    image_denoised = stack.remove_background_gaussian(
-        image,
-        sigma=large_sigma)
-
-    # build a reference median spot
-    reference_spot = build_reference_spot(
-        image_denoised,
-        spots,
-        voxel_size_z, voxel_size_yx, psf_z, psf_yx,
-        alpha)
-
-    # case with an empty frame as reference spot
-    if reference_spot.sum() == 0:
-        cluster = np.array([], dtype=np.int64).reshape((0, ndim + 4))
-        return spots, cluster, reference_spot
-
-    # fit a gaussian function on the reference spot to be able to simulate it
-    parameters_fitted = modelize_spot(
-        reference_spot, voxel_size_z, voxel_size_yx, psf_z, psf_yx)
-    if ndim == 3:
-        sigma_z, sigma_yx, amplitude, background = parameters_fitted
-    else:
-        sigma_z = None
-        sigma_yx, amplitude, background = parameters_fitted
-
-    # use connected components to detect potential clusters
-    cluster_regions, spots_out_cluster, cluster_size = get_clustered_region(
-        image_denoised,
-        spots,
-        voxel_size_z, voxel_size_yx, psf_z, psf_yx,
-        beta)
-
-    # case where no cluster where detected
-    if cluster_regions.size == 0:
-        cluster = np.array([], dtype=np.int64).reshape((0, ndim + 4))
-        return spots, cluster, reference_spot
-
-    # precompute gaussian function values
-    max_grid = max(200, cluster_size + 1)
-    precomputed_gaussian = precompute_erf(
-        voxel_size_z, voxel_size_yx, sigma_z, sigma_yx, max_grid=max_grid)
-
-    # fit gaussian mixtures in the cluster regions
-    spots_in_cluster, clusters = fit_gaussian_mixture(
-        image=image_denoised,
-        cluster_regions=cluster_regions,
-        voxel_size_z=voxel_size_z,
-        voxel_size_yx=voxel_size_yx,
-        sigma_z=sigma_z,
-        sigma_yx=sigma_yx,
-        amplitude=amplitude,
-        background=background,
-        precomputed_gaussian=precomputed_gaussian)
-
-    # normally the number of detected spots should increase
-    if len(spots_out_cluster) + len(spots_in_cluster) < len(spots):
-        warnings.warn("Problem occurs during the decomposition of clusters. "
-                      "Less spots are detected after the decomposition than "
-                      "before.",
-                      UserWarning)
-
-    # merge outside and inside spots
-    spots = np.concatenate((spots_out_cluster, spots_in_cluster[:, :ndim]),
-                           axis=0)
-
-    return spots, clusters, reference_spot
 
 
 # ### Reference spot ###
@@ -211,17 +38,16 @@ def build_reference_spot(image, spots, voxel_size_z=None, voxel_size_yx=100,
     voxel_size_yx : int or float
         Size of a voxel on the yx plan, in nanometer.
     psf_z : int or float or None
-        Theoretical size of the PSF emitted by a spot in the z plan,
-        in nanometer. If None, image is considered in 2-d.
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, image is considered in 2-d.
     psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
     alpha : int or float
         Intensity score of the reference spot, between 0 and 1. If 0, reference
         spot approximates the spot with the lowest intensity. If 1, reference
         spot approximates the brightest spot. Default is 0.5.
 
-
     Returns
     -------
     reference_spot : np.ndarray
@@ -258,7 +84,7 @@ def build_reference_spot(image, spots, voxel_size_z=None, voxel_size_yx=100,
         voxel_size_z, psf_z = None, None
 
     # compute radius
-    radius = get_radius(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
+    radius = stack.get_radius(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
 
     # build reference spot
     if image.ndim == 3:
@@ -314,8 +140,8 @@ def _build_reference_spot_3d(image, spots, radius, alpha):
         spot_z, spot_y, spot_x = candidate_spots[i_spot, :]
 
         # get the volume of the spot
-        image_spot = _get_spot_volume(image, spot_z, spot_y, spot_x,
-                                      radius_z, radius_yx)
+        image_spot, _, = _get_spot_volume(image, spot_z, spot_y, spot_x,
+                                          radius_z, radius_yx)
 
         # keep images that are not cropped by the borders
         if image_spot.shape == (z_shape, yx_shape, yx_shape):
@@ -362,6 +188,8 @@ def _get_spot_volume(image, spot_z, spot_y, spot_x, radius_z, radius_yx):
     -------
     image_spot : np.ndarray
         Reference spot in 3-d.
+    _ : Tuple[int]
+        Lower zyx coordinates of the crop.
 
     """
     # get boundaries of the volume surrounding the spot
@@ -377,7 +205,7 @@ def _get_spot_volume(image, spot_z, spot_y, spot_x, radius_z, radius_yx):
                        y_spot_min:y_spot_max + 1,
                        x_spot_min:x_spot_max + 1]
 
-    return image_spot
+    return image_spot, (z_spot_min, y_spot_min, x_spot_min)
 
 
 def _build_reference_spot_2d(image, spots, radius, alpha):
@@ -423,7 +251,7 @@ def _build_reference_spot_2d(image, spots, radius, alpha):
         spot_y, spot_x = candidate_spots[i_spot, :]
 
         # get the volume of the spot
-        image_spot = _get_spot_surface(image, spot_y, spot_x, radius_yx)
+        image_spot, _ = _get_spot_surface(image, spot_y, spot_x, radius_yx)
 
         # keep images that are not cropped by the borders
         if image_spot.shape == (yx_shape, yx_shape):
@@ -465,6 +293,8 @@ def _get_spot_surface(image, spot_y, spot_x, radius_yx):
     -------
     image_spot : np.ndarray
         Reference spot in 2-d.
+    _ : Tuple[int]
+        Lower yx coordinates of the crop.
 
     """
     # get boundaries of the surface surrounding the spot
@@ -477,13 +307,13 @@ def _get_spot_surface(image, spot_y, spot_x, radius_yx):
     image_spot = image[y_spot_min:y_spot_max + 1,
                        x_spot_min:x_spot_max + 1]
 
-    return image_spot
+    return image_spot, (y_spot_min, x_spot_min)
 
 
 # ### Spot modelization ###
 
 def modelize_spot(reference_spot, voxel_size_z=None, voxel_size_yx=100,
-                  psf_z=None, psf_yx=200):
+                  psf_z=None, psf_yx=200, return_coord=False):
     """Fit a gaussian function on the reference spot.
 
     Parameters
@@ -496,23 +326,31 @@ def modelize_spot(reference_spot, voxel_size_z=None, voxel_size_yx=100,
     voxel_size_yx : int or float
         Size of a voxel on the yx plan, in nanometer.
     psf_z : int or float or None
-        Theoretical size of the PSF emitted by a spot in the z plan,
-        in nanometer. If None, reference spot is considered in 2-d.
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, reference spot is considered in 2-d.
     psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
+    return_coord : bool
+        Return spot coordinates.
 
     Returns
     -------
     parameters_fitted : Tuple[float]
-        - sigma_z : float
+        * mu_z : float (optional)
+            Coordinate of the spot center along the z axis, in pixel.
+        * mu_y : float (optional)
+            Coordinate of the spot center along the y axis, in pixel.
+        * mu_x : float (optional)
+            Coordinate of the spot center along the x axis, in pixel.
+        * sigma_z : float
             Standard deviation of the spot along the z axis, in pixel.
             Available only for a 3-d modelization.
-        - sigma_yx : float
+        * sigma_yx : float
             Standard deviation of the spot along the yx axis, in pixel.
-        - amplitude : float
+        * amplitude : float
             Amplitude of the spot.
-        - background : float
+        * background : float
             Background minimum value of the image.
 
     """
@@ -523,7 +361,8 @@ def modelize_spot(reference_spot, voxel_size_z=None, voxel_size_yx=100,
     stack.check_parameter(voxel_size_z=(int, float, type(None)),
                           voxel_size_yx=(int, float),
                           psf_z=(int, float, type(None)),
-                          psf_yx=(int, float))
+                          psf_yx=(int, float),
+                          return_coord=bool)
 
     # check number of dimensions
     ndim = reference_spot.ndim
@@ -537,7 +376,7 @@ def modelize_spot(reference_spot, voxel_size_z=None, voxel_size_yx=100,
         voxel_size_z, psf_z = None, None
 
     # initialize a grid representing the reference spot
-    grid, centroid_coord = _initialize_grid(
+    grid, centroid_coord = initialize_grid(
         image_spot=reference_spot,
         voxel_size_z=voxel_size_z,
         voxel_size_yx=voxel_size_yx,
@@ -577,25 +416,36 @@ def modelize_spot(reference_spot, voxel_size_z=None, voxel_size_yx=100,
 
     # get optimized parameters to modelize the reference spot as a gaussian
     if ndim == 3:
+        mu_z = popt[0]
+        mu_y = popt[1]
+        mu_x = popt[2]
         sigma_z = popt[3]
         sigma_yx = popt[4]
         amplitude = popt[5]
         background = popt[6]
 
-        return sigma_z, sigma_yx, amplitude, background
+        if return_coord:
+            return mu_z, mu_y, mu_x, sigma_z, sigma_yx, amplitude, background
+        else:
+            return sigma_z, sigma_yx, amplitude, background
 
     else:
+        mu_y = popt[0]
+        mu_x = popt[1]
         sigma_yx = popt[2]
         amplitude = popt[3]
         background = popt[4]
 
-        return sigma_yx, amplitude, background
+        if return_coord:
+            return mu_y, mu_x, sigma_yx, amplitude, background
+        else:
+            return sigma_yx, amplitude, background
 
 
 # ### Spot modelization: initialization ###
 
-def _initialize_grid(image_spot, voxel_size_z, voxel_size_yx,
-                     return_centroid=False):
+def initialize_grid(image_spot, voxel_size_z, voxel_size_yx,
+                    return_centroid=False):
     """Build a grid in nanometer to compute gaussian function values over a
     full volume or surface.
 
@@ -610,6 +460,7 @@ def _initialize_grid(image_spot, voxel_size_z, voxel_size_yx,
         Size of a voxel on the yx plan, in nanometer.
     return_centroid : bool
         Compute centroid estimation of the grid.
+
     Returns
     -------
     grid : np.ndarray, np.float32
@@ -657,6 +508,7 @@ def _initialize_grid_3d(image_spot, voxel_size_z, voxel_size_yx,
         Size of a voxel on the yx plan, in nanometer.
     return_centroid : bool
         Compute centroid estimation of the grid.
+
     Returns
     -------
     grid : np.ndarray, np.float32
@@ -676,15 +528,15 @@ def _initialize_grid_3d(image_spot, voxel_size_z, voxel_size_yx,
     # build meshgrid
     zz, yy, xx = np.meshgrid(np.arange(nb_z), np.arange(nb_y), np.arange(nb_x),
                              indexing="ij")
-    zz *= voxel_size_z
-    yy *= voxel_size_yx
-    xx *= voxel_size_yx
+    zz = zz.astype(np.float32) * float(voxel_size_z)
+    yy = yy.astype(np.float32) * float(voxel_size_yx)
+    xx = xx.astype(np.float32) * float(voxel_size_yx)
 
     # format result
     grid = np.zeros((3, nb_pixels), dtype=np.float32)
-    grid[0] = np.reshape(zz, (1, nb_pixels)).astype(np.float32)
-    grid[1] = np.reshape(yy, (1, nb_pixels)).astype(np.float32)
-    grid[2] = np.reshape(xx, (1, nb_pixels)).astype(np.float32)
+    grid[0] = np.reshape(zz, (1, nb_pixels))
+    grid[1] = np.reshape(yy, (1, nb_pixels))
+    grid[2] = np.reshape(xx, (1, nb_pixels))
 
     # compute centroid of the grid
     if return_centroid:
@@ -713,6 +565,7 @@ def _initialize_grid_2d(image_spot, voxel_size_yx, return_centroid=False):
         Size of a voxel on the yx plan, in nanometer.
     return_centroid : bool
         Compute centroid estimation of the grid.
+
     Returns
     -------
     grid : np.ndarray, np.float32
@@ -729,13 +582,13 @@ def _initialize_grid_2d(image_spot, voxel_size_yx, return_centroid=False):
 
     # build meshgrid
     yy, xx = np.meshgrid(np.arange(nb_y), np.arange(nb_x), indexing="ij")
-    yy *= voxel_size_yx
-    xx *= voxel_size_yx
+    yy = yy.astype(np.float32) * float(voxel_size_yx)
+    xx = xx.astype(np.float32) * float(voxel_size_yx)
 
     # format result
     grid = np.zeros((2, nb_pixels), dtype=np.float32)
-    grid[0] = np.reshape(yy, (1, nb_pixels)).astype(np.float32)
-    grid[1] = np.reshape(xx, (1, nb_pixels)).astype(np.float32)
+    grid[0] = np.reshape(yy, (1, nb_pixels))
+    grid[1] = np.reshape(xx, (1, nb_pixels))
 
     # compute centroid of the grid
     if return_centroid:
@@ -849,16 +702,16 @@ def _objective_function_3d(voxel_size_z, voxel_size_yx, psf_z, psf_yx,
             and psf_yx is not None
             and psf_amplitude is None):
         def f(grid, mu_z, mu_y, mu_x, psf_amplitude, psf_background):
-            values = _gaussian_3d(grid=grid,
-                                  mu_z=mu_z,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_z=psf_z,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_z=voxel_size_z,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_3d(grid=grid,
+                                 mu_z=mu_z,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_z=psf_z,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_z=voxel_size_z,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     # amplitude is known, we fit sigma, mu and background
@@ -866,16 +719,16 @@ def f(grid, mu_z, mu_y, mu_x, psf_amplitude, psf_background):
           and psf_z is None
           and psf_yx is None):
         def f(grid, mu_z, mu_y, mu_x, psf_z, psf_yx, psf_background):
-            values = _gaussian_3d(grid=grid,
-                                  mu_z=mu_z,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_z=psf_z,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_z=voxel_size_z,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_3d(grid=grid,
+                                 mu_z=mu_z,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_z=psf_z,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_z=voxel_size_z,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     # amplitude and sigma are known, we fit mu and background
@@ -883,16 +736,16 @@ def f(grid, mu_z, mu_y, mu_x, psf_z, psf_yx, psf_background):
           and psf_z is not None
           and psf_yx is not None):
         def f(grid, mu_z, mu_y, mu_x, psf_background):
-            values = _gaussian_3d(grid=grid,
-                                  mu_z=mu_z,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_z=psf_z,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_z=voxel_size_z,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_3d(grid=grid,
+                                 mu_z=mu_z,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_z=psf_z,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_z=voxel_size_z,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     # we fit mu, sigma, amplitude and background
@@ -901,16 +754,16 @@ def f(grid, mu_z, mu_y, mu_x, psf_background):
           and psf_yx is None):
         def f(grid, mu_z, mu_y, mu_x, psf_z, psf_yx, psf_amplitude,
               psf_background):
-            values = _gaussian_3d(grid=grid,
-                                  mu_z=mu_z,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_z=psf_z,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_z=voxel_size_z,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_3d(grid=grid,
+                                 mu_z=mu_z,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_z=psf_z,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_z=voxel_size_z,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     else:
@@ -920,13 +773,12 @@ def f(grid, mu_z, mu_y, mu_x, psf_z, psf_yx, psf_amplitude,
     return f
 
 
-def _gaussian_3d(grid, mu_z, mu_y, mu_x, sigma_z, sigma_yx, voxel_size_z,
-                 voxel_size_yx, psf_amplitude, psf_background,
-                 precomputed=None):
-    """Compute the gaussian function over the grid 'xdata' representing a
-    volume V with shape (V_z, V_y, V_x).
-
-    # TODO add equations
+# TODO add equations in the docstring
+def gaussian_3d(grid, mu_z, mu_y, mu_x, sigma_z, sigma_yx, voxel_size_z,
+                voxel_size_yx, psf_amplitude, psf_background,
+                precomputed=None):
+    """Compute the gaussian function over the grid representing a volume V
+    with shape (V_z, V_y, V_x).
 
     Parameters
     ----------
@@ -975,9 +827,9 @@ def _gaussian_3d(grid, mu_z, mu_y, mu_x, sigma_z, sigma_yx, voxel_size_z,
         table_erf_x = precomputed[2]
 
         # get indices for the tables
-        i_z = np.around(np.abs(meshgrid_z - mu_z) / 5).astype(np.int64)
-        i_y = np.around(np.abs(meshgrid_y - mu_y) / 5).astype(np.int64)
-        i_x = np.around(np.abs(meshgrid_x - mu_x) / 5).astype(np.int64)
+        i_z = np.abs(meshgrid_z - mu_z).astype(np.int64)
+        i_y = np.abs(meshgrid_y - mu_y).astype(np.int64)
+        i_x = np.abs(meshgrid_x - mu_x).astype(np.int64)
 
         # get precomputed values
         voxel_integral_z = table_erf_z[i_z, 1]
@@ -1038,60 +890,59 @@ def _objective_function_2d(voxel_size_yx, psf_yx, psf_amplitude=None):
     # sigma is known, we fit mu, amplitude and background
     if psf_yx is not None and psf_amplitude is None:
         def f(grid, mu_y, mu_x, psf_amplitude, psf_background):
-            values = _gaussian_2d(grid=grid,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_2d(grid=grid,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     # amplitude is known, we fit sigma, mu and background
     elif psf_amplitude is not None and psf_yx is None:
         def f(grid, mu_y, mu_x, psf_yx, psf_background):
-            values = _gaussian_2d(grid=grid,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_2d(grid=grid,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     # amplitude and sigma are known, we fit mu and background
     elif psf_amplitude is not None and psf_yx is not None:
         def f(grid, mu_y, mu_x, psf_background):
-            values = _gaussian_2d(grid=grid,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_2d(grid=grid,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     # we fit mu, sigma, amplitude and background
     else:
         def f(grid, mu_y, mu_x, psf_yx, psf_amplitude, psf_background):
-            values = _gaussian_2d(grid=grid,
-                                  mu_y=mu_y,
-                                  mu_x=mu_x,
-                                  sigma_yx=psf_yx,
-                                  voxel_size_yx=voxel_size_yx,
-                                  psf_amplitude=psf_amplitude,
-                                  psf_background=psf_background)
+            values = gaussian_2d(grid=grid,
+                                 mu_y=mu_y,
+                                 mu_x=mu_x,
+                                 sigma_yx=psf_yx,
+                                 voxel_size_yx=voxel_size_yx,
+                                 psf_amplitude=psf_amplitude,
+                                 psf_background=psf_background)
             return values
 
     return f
 
 
-def _gaussian_2d(grid, mu_y, mu_x, sigma_yx, voxel_size_yx, psf_amplitude,
-                 psf_background, precomputed=None):
-    """Compute the gaussian function over the grid 'xdata' representing a
-    surface S with shape (S_y, S_x).
-
-    # TODO add equations
+# TODO add equations in the docstring
+def gaussian_2d(grid, mu_y, mu_x, sigma_yx, voxel_size_yx, psf_amplitude,
+                psf_background, precomputed=None):
+    """Compute the gaussian function over the grid representing a surface S
+    with shape (S_y, S_x).
 
     Parameters
     ----------
@@ -1132,8 +983,8 @@ def _gaussian_2d(grid, mu_y, mu_x, sigma_yx, voxel_size_yx, psf_amplitude,
         table_erf_x = precomputed[1]
 
         # get indices for the tables
-        i_y = np.around(np.abs(meshgrid_y - mu_y) / 5).astype(np.int64)
-        i_x = np.around(np.abs(meshgrid_x - mu_x) / 5).astype(np.int64)
+        i_y = np.abs(meshgrid_y - mu_y).astype(np.int64)
+        i_x = np.abs(meshgrid_x - mu_x).astype(np.int64)
 
         # get precomputed values
         voxel_integral_y = table_erf_y[i_y, 1]
@@ -1245,7 +1096,7 @@ def _fit_gaussian(f, grid, image_spot, p0, lower_bound=None, upper_bound=None):
 
 def precompute_erf(voxel_size_z=None, voxel_size_yx=100, sigma_z=None,
                    sigma_yx=200, max_grid=200):
-    """Precompute different values for the erf with a resolution of 5 nm.
+    """Precompute different values for the erf with a nanometer resolution.
 
     Parameters
     ----------
@@ -1264,30 +1115,32 @@ def precompute_erf(voxel_size_z=None, voxel_size_yx=100, sigma_z=None,
     Returns
     -------
     table_erf : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
-        (nb_value, 2). One table per dimension.
+        Tuple with tables of precomputed values for the erf, with shape
+        (nb_value, 2). One table per dimension. First column is the coordinate
+        along the table dimension. Second column is the precomputed erf value.
 
     """
     # check parameters
-    stack.check_parameter(voxel_size_z=(int, type(None)),
-                          voxel_size_yx=int,
-                          sigma_z=(float, int, type(None)),
-                          sigma_yx=(float, int),
+    stack.check_parameter(voxel_size_z=(int, float, type(None)),
+                          voxel_size_yx=(int, float),
+                          sigma_z=(int, float, type(None)),
+                          sigma_yx=(int, float),
                           max_grid=int)
 
-    # build a grid with a spatial resolution of 5 nm and a size of
+    # build a grid with a spatial resolution of 1 nm and a size of
     # max_grid * resolution nm
-    yy = np.array([i for i in range(0, max_grid * voxel_size_yx, 5)])
-    xx = np.array([i for i in range(0, max_grid * voxel_size_yx, 5)])
+    max_size_yx = np.ceil(max_grid * voxel_size_yx).astype(np.int64)
+    yy = np.array([i for i in range(0, max_size_yx)])
+    xx = np.array([i for i in range(0, max_size_yx)])
     mu_y, mu_x = 0, 0
 
     # compute erf values for this grid
-    erf_y = _rescaled_erf(low=yy - voxel_size_yx/2,
-                          high=yy + voxel_size_yx/2,
+    erf_y = _rescaled_erf(low=yy - voxel_size_yx / 2,
+                          high=yy + voxel_size_yx / 2,
                           mu=mu_y,
                           sigma=sigma_yx)
-    erf_x = _rescaled_erf(low=xx - voxel_size_yx/2,
-                          high=xx + voxel_size_yx/2,
+    erf_x = _rescaled_erf(low=xx - voxel_size_yx / 2,
+                          high=xx + voxel_size_yx / 2,
                           mu=mu_x,
                           sigma=sigma_yx)
 
@@ -1299,7 +1152,8 @@ def precompute_erf(voxel_size_z=None, voxel_size_yx=100, sigma_z=None,
         return table_erf_y, table_erf_x
 
     else:
-        zz = np.array([i for i in range(0, max_grid * voxel_size_z, 5)])
+        max_size_z = np.ceil(max_grid * voxel_size_z).astype(np.int64)
+        zz = np.array([i for i in range(0, max_size_z)])
         mu_z = 0
         erf_z = _rescaled_erf(low=zz - voxel_size_z / 2,
                               high=zz + voxel_size_z / 2,
@@ -1309,47 +1163,36 @@ def precompute_erf(voxel_size_z=None, voxel_size_yx=100, sigma_z=None,
         return table_erf_z, table_erf_y, table_erf_x
 
 
-# ### Clustered regions ###
-
-def get_clustered_region(image, spots, voxel_size_z=None, voxel_size_yx=100,
-                         psf_z=None, psf_yx=200, beta=1):
-    """Detect and filter potential clustered regions.
+# ### Subpixel fitting ###
 
-    A candidate region follows has at least 2 connected pixels above a
-    specific threshold.
+def fit_subpixel(image, spots, voxel_size_z=None, voxel_size_yx=100,
+                 psf_z=None, psf_yx=200):
+    """Fit gaussian signal on every spot to find a subpixel coordinates.
 
     Parameters
     ----------
     image : np.ndarray
         Image with shape (z, y, x) or (y, x).
     spots : np.ndarray, np.int64
-        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+        Coordinate of the spots detected, with shape (nb_spots, 3) or
+        (nb_spots, 2). One coordinate per dimension (zyx or yx coordinates).
     voxel_size_z : int or float or None
-        Height of a voxel, along the z axis, in nanometer. If None, we
-        consider a 2-d image.
+        Height of a voxel, along the z axis, in nanometer. If None, image is
+        considered in 2-d.
     voxel_size_yx : int or float
         Size of a voxel on the yx plan, in nanometer.
     psf_z : int or float or None
-        Theoretical size of the PSF emitted by a spot in the z plan,
-        in nanometer. If None, we consider a 2-d image.
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, image is considered in 2-d.
     psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
-    beta : int or float
-        Multiplicative factor for the intensity threshold of a cluster region.
-        Default is 1. Threshold is computed with the formula :
-            threshold = beta * max(median_spot)
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
 
     Returns
     -------
-    cluster_regions : np.ndarray
-        Array with filtered skimage.measure._regionprops._RegionProperties.
-    spots_out_region : np.ndarray, np.int64
-        Coordinate of the spots detected out of cluster, with shape
-        (nb_spots, 3) or (nb_spots, 2). One coordinate per dimension (zyx or
-        yx coordinates).
-    max_size : int
-        Maximum size of the regions.
+    spots_subpixel : np.ndarray, np.float64
+        Coordinate of the spots detected, with shape (nb_spots, 3) or
+        (nb_spots, 2). One coordinate per dimension (zyx or yx coordinates).
 
     """
     # check parameters
@@ -1360,11 +1203,7 @@ def get_clustered_region(image, spots, voxel_size_z=None, voxel_size_yx=100,
     stack.check_parameter(voxel_size_z=(int, float, type(None)),
                           voxel_size_yx=(int, float),
                           psf_z=(int, float, type(None)),
-                          psf_yx=(int, float),
-                          beta=(int, float))
-    if beta < 0:
-        raise ValueError("'beta' should be a positive value, not {0}"
-                         .format(beta))
+                          psf_yx=(int, float))
 
     # check number of dimensions
     ndim = image.ndim
@@ -1381,504 +1220,153 @@ def get_clustered_region(image, spots, voxel_size_z=None, voxel_size_yx=100,
     if ndim == 2:
         voxel_size_z, psf_z = None, None
 
-    # estimate median spot value and a threshold to detect clustered regions
-    median_spot = build_reference_spot(
-        image,
-        spots,
-        voxel_size_z, voxel_size_yx, psf_z, psf_yx,
-        alpha=0.5)
-    threshold = int(median_spot.max() * beta)
-
-    # get connected regions
-    connected_regions = _get_connected_region(image, threshold)
-
-    # filter connected regions
-    (cluster_regions, spots_out_region, max_size) = _filter_connected_region(
-        image, connected_regions, spots)
-
-    return cluster_regions, spots_out_region, max_size
-
-
-def _get_connected_region(image, threshold):
-    """Find connected regions above a fixed threshold.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image with shape (z, y, x) or (y, x).
-    threshold : int or float
-        A threshold to detect peaks.
-
-    Returns
-    -------
-    cc : np.ndarray, np.int64
-        Image labelled with shape (z, y, x) or (y, x).
-
-    """
-    # Compute binary mask of the filtered image
-    mask = image > threshold
-
-    # find connected components
-    cc = label(mask)
-
-    return cc
-
-
-def _filter_connected_region(image, connected_component, spots):
-    """Filter clustered regions (defined as connected component regions).
-
-    A candidate region has at least 2 connected pixels
-    above a specific threshold.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image with shape (z, y, x) or (y, x).
-    connected_component : np.ndarray, np.int64
-        Image labelled with shape (z, y, x) or (y, x).
-    spots : np.ndarray, np.int64
-        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2).
-
-    Returns
-    -------
-    regions_filtered : np.ndarray
-        Array with filtered skimage.measure._regionprops._RegionProperties.
-    spots_out_region : np.ndarray, np.int64
-        Coordinate of the spots outside the regions with shape (nb_spots, 3)
-        or (nb_spots, 2).
-    max_region_size : int
-        Maximum size of the regions.
-
-    """
-    # get properties of the different connected regions
-    regions = regionprops(connected_component, intensity_image=image)
-
-    # get different features of the regions
-    area = []
-    bbox = []
-    for i, region in enumerate(regions):
-        area.append(region.area)
-        bbox.append(region.bbox)
-    regions = np.array(regions)
-    area = np.array(area)
-    bbox = np.array(bbox)
-
-    # keep regions with a minimum size
-    big_area = area >= 2
-    regions_filtered = regions[big_area]
-    bbox_filtered = bbox[big_area]
-
-    # case where no region big enough were detected
-    if regions.size == 0:
-        regions_filtered = np.array([])
-        return regions_filtered, spots, 0
-
-    spots_out_region, max_region_size = _filter_spot_out_candidate_regions(
-        bbox_filtered, spots, nb_dim=image.ndim)
-
-    return regions_filtered, spots_out_region, max_region_size
-
-
-def _filter_spot_out_candidate_regions(candidate_bbox, spots, nb_dim):
-    """Filter spots out of the potential clustered regions.
-
-    Parameters
-    ----------
-    candidate_bbox : List[Tuple]
-        List of Tuples with the bounding box coordinates.
-    spots : np.ndarray, np.int64
-        Coordinate of the spots with shape (nb_spots, 3) or (nb_spots, 2).
-    nb_dim : int
-        Number of dimensions to consider (2 or 3).
-
-    Returns
-    -------
-    spots_out_region : np.ndarray, np.int64
-        Coordinate of the spots outside the regions with shape (nb_spots, 3)
-        or (nb_spots, 2).
-    max_region_size : int
-        Maximum size of the regions.
+    # compute radius
+    radius = stack.get_radius(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
 
-    """
-    # initialization
-    mask_spots_out = np.ones(spots[:, 0].shape, dtype=bool)
-    max_region_size = 0
-
-    # get detected spots outside 3-d regions
-    if nb_dim == 3:
-        for box in candidate_bbox:
-            (min_z, min_y, min_x, max_z, max_y, max_x) = box
-
-            # get the size of the biggest region
-            size_z = max_z - min_z
-            size_y = max_y - min_y
-            size_x = max_x - min_x
-            max_region_size = max(max_region_size, size_z, size_y, size_x)
-
-            # get coordinates of spots inside the region
-            mask_spots_in = spots[:, 0] < max_z
-            mask_spots_in = (mask_spots_in & (spots[:, 1] < max_y))
-            mask_spots_in = (mask_spots_in & (spots[:, 2] < max_x))
-            mask_spots_in = (mask_spots_in & (min_z <= spots[:, 0]))
-            mask_spots_in = (mask_spots_in & (min_y <= spots[:, 1]))
-            mask_spots_in = (mask_spots_in & (min_x <= spots[:, 2]))
-            mask_spots_out = mask_spots_out & (~mask_spots_in)
-
-    # get detected spots outside 2-d regions
-    else:
-        for box in candidate_bbox:
-            (min_y, min_x, max_y, max_x) = box
+    # loop over every spot
+    spots_subpixel = []
+    for coord in spots[:, :ndim]:
 
-            # get the size of the biggest region
-            size_y = max_y - min_y
-            size_x = max_x - min_x
-            max_region_size = max(max_region_size, size_y, size_x)
+        # fit subpixel coordinates
+        if ndim == 3:
+            subpixel_coord = _fit_subpixel_3d(
+                image, coord, radius,
+                voxel_size_z, voxel_size_yx, psf_z, psf_yx)
 
-            # get coordinates of spots inside the region
-            mask_spots_in = spots[:, 0] < max_y
-            mask_spots_in = (mask_spots_in & (spots[:, 1] < max_x))
-            mask_spots_in = (mask_spots_in & (min_y <= spots[:, 0]))
-            mask_spots_in = (mask_spots_in & (min_x <= spots[:, 1]))
-            mask_spots_out = mask_spots_out & (~mask_spots_in)
+        else:
+            subpixel_coord = _fit_subpixel_2d(
+                image, coord, radius, voxel_size_yx, psf_yx)
 
-    # keep apart spots inside a region
-    spots_out_region = spots.copy()
-    spots_out_region = spots_out_region[mask_spots_out]
+        spots_subpixel.append(subpixel_coord)
 
-    return spots_out_region, int(max_region_size)
+    # format results
+    spots_subpixel = np.stack(spots_subpixel)
 
+    return spots_subpixel
 
-# ### Gaussian simulation ###
 
-def fit_gaussian_mixture(image, cluster_regions, voxel_size_z=None,
-                         voxel_size_yx=100, sigma_z=None, sigma_yx=200,
-                         amplitude=100, background=0,
-                         precomputed_gaussian=None):
-    """Fit as many gaussians as possible in the candidate clustered regions.
+def _fit_subpixel_3d(image, coord, radius, voxel_size_z, voxel_size_yx, psf_z,
+                     psf_yx):
+    """Fit a gaussian in a 3-d image.
 
     Parameters
     ----------
     image : np.ndarray
-        Image with shape (z, y, x) or (y, x).
-    cluster_regions : np.ndarray
-        Array with filtered skimage.measure._regionprops._RegionProperties.
+        Image with shape (z, y, x).
+    coord : np.ndarray, np.int64
+        Coordinate of the spot detected, with shape (3,). One coordinate per
+        dimension (zyx coordinates).
+    radius : Tuple[float]
+        Radius in pixels of the detected spots, one element per dimension.
     voxel_size_z : int or float or None
-        Height of a voxel, along the z axis, in nanometer. If None, we
-        consider a 2-d image.
-    voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
-    sigma_z : int or float or None
-        Standard deviation of the gaussian along the z axis, in nanometer. If
-        None, we consider a 2-d image.
-    sigma_yx : int or float
-        Standard deviation of the gaussian along the yx axis, in nanometer.
-    amplitude : float
-        Amplitude of the gaussian.
-    background : float
-        Background minimum value of the image.
-    precomputed_gaussian : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
-        (nb_value, 2). One table per dimension.
-
-    Returns
-    -------
-    spots_in_cluster : np.ndarray, np.int64
-        spots_in_cluster : np.ndarray, np.int64
-        Coordinate of the spots detected inside cluster, with shape
-        (nb_spots, 4) or (nb_spots, 3). One coordinate per dimension (zyx or
-        yx coordinates) plus the index of the cluster.
-    clusters : np.ndarray, np.int64
-        Array with shape (nb_cluster, 7) or (nb_cluster, 6). One coordinate
-        per dimension for the cluster centroid (zyx or yx coordinates), the
-        number of RNAs detected in the cluster, the area of the cluster
-        region, its average intensity value and its index.
-
-    """
-    # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    stack.check_parameter(cluster_regions=np.ndarray,
-                          voxel_size_z=(int, float, type(None)),
-                          voxel_size_yx=(int, float),
-                          sigma_z=(int, float, type(None)),
-                          sigma_yx=(int, float),
-                          amplitude=float,
-                          background=float)
-    if background < 0:
-        raise ValueError("Background value can't be negative: {0}"
-                         .format(background))
-
-    # check number of dimensions
-    ndim = image.ndim
-    if ndim == 3 and voxel_size_z is None:
-        raise ValueError("Provided image has {0} dimensions but "
-                         "'voxel_size_z' parameter is missing.".format(ndim))
-    if ndim == 3 and sigma_z is None:
-        raise ValueError("Provided image has {0} dimensions but "
-                         "'sigma_z' parameter is missing.".format(ndim))
-    if ndim == 2:
-        voxel_size_z, sigma_z = None, None
-
-    # fit gaussian mixtures in the cluster regions...
-    spots_in_cluster = []
-    clusters = []
-
-    # ... for 3-d regions...
-    if image.ndim == 3:
-
-        for i_cluster, region in enumerate(cluster_regions):
-            image_region, _, pos_gaussian = _gaussian_mixture_3d(
-                image,
-                region,
-                voxel_size_z,
-                voxel_size_yx,
-                sigma_z,
-                sigma_yx,
-                amplitude,
-                background,
-                precomputed_gaussian)
-
-            # get coordinates of spots and clusters in the original image
-            box = region.bbox
-            (min_z, min_y, min_x, _, _, _) = box
-            pos_gaussian = np.array(pos_gaussian, dtype=np.float64)
-            pos_gaussian[:, 0] = (pos_gaussian[:, 0] / voxel_size_z) + min_z
-            pos_gaussian[:, 1] = (pos_gaussian[:, 1] / voxel_size_yx) + min_y
-            pos_gaussian[:, 2] = (pos_gaussian[:, 2] / voxel_size_yx) + min_x
-            spots_in_cluster_ = np.zeros((pos_gaussian.shape[0], 4),
-                                         dtype=np.int64)
-            spots_in_cluster_[:, :3] = pos_gaussian
-            spots_in_cluster_[:, 3] = i_cluster
-            spots_in_cluster.append(spots_in_cluster_)
-            cluster_z, cluster_y, cluster_x = tuple(pos_gaussian[0])
-            nb_rna_cluster = pos_gaussian.shape[0]
-            cluster_area = region.area
-            cluster_intensity = region.mean_intensity
-            clusters.append([cluster_z, cluster_y, cluster_x, nb_rna_cluster,
-                             cluster_area, cluster_intensity, i_cluster])
-
-    # ... or 2-d regions
-    else:
-
-        for i_cluster, region in enumerate(cluster_regions):
-            image_region, _, pos_gaussian = _gaussian_mixture_2d(
-                image,
-                region,
-                voxel_size_yx,
-                sigma_yx,
-                amplitude,
-                background,
-                precomputed_gaussian)
-
-            # get coordinates of spots and clusters in the original image
-            box = region.bbox
-            (min_y, min_x, _, _) = box
-            pos_gaussian = np.array(pos_gaussian, dtype=np.float64)
-            pos_gaussian[:, 0] = (pos_gaussian[:, 0] / voxel_size_yx) + min_y
-            pos_gaussian[:, 1] = (pos_gaussian[:, 1] / voxel_size_yx) + min_x
-            spots_in_cluster_ = np.zeros((pos_gaussian.shape[0], 3),
-                                         dtype=np.int64)
-            spots_in_cluster_[:, :2] = pos_gaussian
-            spots_in_cluster_[:, 2] = i_cluster
-            spots_in_cluster.append(spots_in_cluster_)
-            cluster_y, cluster_x = tuple(pos_gaussian[0])
-            nb_rna_cluster = pos_gaussian.shape[0]
-            cluster_area = region.area
-            cluster_intensity = region.mean_intensity
-            clusters.append([cluster_y, cluster_x, nb_rna_cluster,
-                             cluster_area, cluster_intensity, i_cluster])
-
-    spots_in_cluster = np.concatenate(spots_in_cluster, axis=0)
-    clusters = np.array(clusters, dtype=np.int64)
-
-    return spots_in_cluster, clusters
-
-
-def _gaussian_mixture_3d(image, region, voxel_size_z, voxel_size_yx, sigma_z,
-                         sigma_yx, amplitude, background, precomputed_gaussian,
-                         limit_gaussian=1000):
-    """Fit as many 3-d gaussians as possible in a potential clustered region.
-
-    Parameters
-    ----------
-    image : np.ndarray, np.uint
-        A 3-d image with detected spot and shape (z, y, x).
-    region : skimage.measure._regionprops._RegionProperties
-        Properties of a clustered region.
-    voxel_size_z : int or float
         Height of a voxel, along the z axis, in nanometer.
     voxel_size_yx : int or float
         Size of a voxel on the yx plan, in nanometer.
-    sigma_z : int or float
-        Standard deviation of the gaussian along the z axis, in pixel.
-    sigma_yx : int or float
-        Standard deviation of the gaussian along the yx axis, in pixel.
-    amplitude : float
-        Amplitude of the gaussian.
-    background : float
-        Background minimum value of the image.
-    precomputed_gaussian : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
-        (nb_value, 2). One table per dimension.
-    limit_gaussian : int
-        Limit number of gaussian to fit into this region.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan,
+        in nanometer.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan,
+        in nanometer.
 
     Returns
     -------
-    image_region : np.ndarray, np.uint
-        A 3-d image with detected spots and shape (z, y, x).
-    best_simulation : np.ndarray, np.uint
-        A 3-d image with simulated spots and shape (z, y, x).
-    positions_gaussian : List[List]
-        List of positions (as a list [z, y, x]) for the different gaussian
-        simulations used in the mixture.
+    new_coord : List[float]
+        Coordinates of the spot centroid with a subpixel accuracy (one element
+        per dimension).
 
     """
-    # get an image of the region
-    box = tuple(region.bbox)
-    image_region = image[box[0]:box[3], box[1]:box[4], box[2]:box[5]]
-    image_region_raw = np.reshape(image_region, image_region.size)
-    image_region_raw = image_region_raw.astype(np.float64)
-
-    # build a grid to represent this image
-    grid = _initialize_grid_3d(image_region, voxel_size_z, voxel_size_yx)
-
-    # add a gaussian for each local maximum while the RSS decreases
-    simulation = np.zeros_like(image_region_raw)
-    residual = image_region_raw - simulation
-    ssr = np.sum(residual ** 2)
-    diff_ssr = -1
-    nb_gaussian = 0
-    best_simulation = simulation.copy()
-    positions_gaussian = []
-    while diff_ssr < 0 or nb_gaussian == limit_gaussian:
-        position_gaussian = np.argmax(residual)
-        positions_gaussian.append(list(grid[:, position_gaussian]))
-        simulation += _gaussian_3d(grid=grid,
-                                   mu_z=float(positions_gaussian[-1][0]),
-                                   mu_y=float(positions_gaussian[-1][1]),
-                                   mu_x=float(positions_gaussian[-1][2]),
-                                   sigma_z=sigma_z,
-                                   sigma_yx=sigma_yx,
+    # extract spot image
+    image_spot, bbox_low = _get_spot_volume(
+        image, coord[0], coord[1], coord[2], radius[0], radius[1])
+
+    # fit gaussian
+    try:
+        parameters = modelize_spot(image_spot,
                                    voxel_size_z=voxel_size_z,
                                    voxel_size_yx=voxel_size_yx,
-                                   psf_amplitude=amplitude,
-                                   psf_background=background,
-                                   precomputed=precomputed_gaussian)
-        residual = image_region_raw - simulation
-        new_ssr = np.sum(residual ** 2)
-        diff_ssr = new_ssr - ssr
-        ssr = new_ssr
-        nb_gaussian += 1
-        background = 0
-
-        if diff_ssr < 0:
-            best_simulation = simulation.copy()
-
-    if 1 < nb_gaussian < limit_gaussian:
-        positions_gaussian.pop(-1)
-    elif nb_gaussian == limit_gaussian:
-        warnings.warn("Problem occurs during the decomposition of a cluster. "
-                      "More than {0} spots seem to be necessary to reproduce "
-                      "the clustered region and decomposition was stopped "
-                      "early. Set a higher limit or check a potential "
-                      "artifact in the image.".format(limit_gaussian),
-                      UserWarning)
+                                   psf_z=psf_z, psf_yx=psf_yx,
+                                   return_coord=True)
+
+        # format coordinates and ensure it is fitted within the spot image
+        z_max, y_max, x_max = image_spot.shape
+        coord_z = parameters[0] / voxel_size_z
+        if coord_z < 0 or coord_z > z_max:
+            coord_z = coord[0]
+        else:
+            coord_z += bbox_low[0]
+        coord_y = parameters[1] / voxel_size_yx
+        if coord_y < 0 or coord_y > y_max:
+            coord_y = coord[1]
+        else:
+            coord_y += bbox_low[1]
+        coord_x = parameters[2] / voxel_size_yx
+        if coord_x < 0 or coord_x > x_max:
+            coord_x = coord[2]
+        else:
+            coord_x += bbox_low[2]
+        new_coord = [coord_z, coord_y, coord_x]
 
-    # TODO clip the image correctly before casting it
-    best_simulation = np.reshape(best_simulation, image_region.shape)
-    best_simulation = best_simulation.astype(image_region.dtype)
+    # if a spot is ill-conditioned, we simply keep its original coordinates
+    except RuntimeError:
+        new_coord = list(coord)
 
-    return image_region, best_simulation, positions_gaussian
+    return new_coord
 
 
-def _gaussian_mixture_2d(image, region, voxel_size_yx, sigma_yx, amplitude,
-                         background, precomputed_gaussian,
-                         limit_gaussian=1000):
-    """Fit as many 2-d gaussians as possible in a potential clustered region.
+def _fit_subpixel_2d(image, coord, radius, voxel_size_yx, psf_yx):
+    """Fit a gaussian in a 2-d image.
 
     Parameters
     ----------
-    image : np.ndarray, np.uint
-        A 2-d image with detected spot and shape (y, x).
-    region : skimage.measure._regionprops._RegionProperties
-        Properties of a clustered region.
+    image : np.ndarray
+        Image with shape (y, x).
+    coord : np.ndarray, np.int64
+        Coordinate of the spot detected, with shape (2,). One coordinate per
+        dimension (yx coordinates).
+    radius : Tuple[float]
+        Radius in pixels of the detected spots, one element per dimension.
     voxel_size_yx : int or float
         Size of a voxel on the yx plan, in nanometer.
-    sigma_yx : int or float
-        Standard deviation of the gaussian along the yx axis, in pixel.
-    amplitude : float
-        Amplitude of the gaussian.
-    background : float
-        Background minimum value of the image.
-    precomputed_gaussian : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
-        (nb_value, 2). One table per dimension.
-    limit_gaussian : int
-        Limit number of gaussian to fit into this region.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan,
+        in nanometer.
 
     Returns
     -------
-    image_region : np.ndarray, np.uint
-        A 2-d image with detected spots and shape (y, x).
-    best_simulation : np.ndarray, np.uint
-        A 2-d image with simulated spots and shape (y, x).
-    positions_gaussian : List[List]
-        List of positions (as a list [y, x]) for the different gaussian
-        simulations used in the mixture.
+    new_coord : List[float]
+        Coordinates of the spot centroid with a subpixel accuracy (one element
+        per dimension).
 
     """
-    # get an image of the region
-    box = tuple(region.bbox)
-    image_region = image[box[0]:box[2], box[1]:box[3]]
-    image_region_raw = np.reshape(image_region, image_region.size)
-    image_region_raw = image_region_raw.astype(np.float64)
-
-    # build a grid to represent this image
-    grid = _initialize_grid_2d(image_region, voxel_size_yx)
-
-    # add a gaussian for each local maximum while the RSS decreases
-    simulation = np.zeros_like(image_region_raw)
-    residual = image_region_raw - simulation
-    ssr = np.sum(residual ** 2)
-    diff_ssr = -1
-    nb_gaussian = 0
-    best_simulation = simulation.copy()
-    positions_gaussian = []
-    while diff_ssr < 0 or nb_gaussian == limit_gaussian:
-        position_gaussian = np.argmax(residual)
-        positions_gaussian.append(list(grid[:, position_gaussian]))
-        simulation += _gaussian_2d(grid=grid,
-                                   mu_y=float(positions_gaussian[-1][0]),
-                                   mu_x=float(positions_gaussian[-1][1]),
-                                   sigma_yx=sigma_yx,
+    # extract spot image
+    image_spot, bbox_low = _get_spot_surface(
+        image, coord[0], coord[1], radius[0])
+
+    # fit gaussian
+    try:
+        parameters = modelize_spot(image_spot,
+                                   voxel_size_z=None,
                                    voxel_size_yx=voxel_size_yx,
-                                   psf_amplitude=amplitude,
-                                   psf_background=background,
-                                   precomputed=precomputed_gaussian)
-        residual = image_region_raw - simulation
-        new_ssr = np.sum(residual ** 2)
-        diff_ssr = new_ssr - ssr
-        ssr = new_ssr
-        nb_gaussian += 1
-        background = 0
-
-        if diff_ssr < 0:
-            best_simulation = simulation.copy()
-
-    if 1 < nb_gaussian < limit_gaussian:
-        positions_gaussian.pop(-1)
-    elif nb_gaussian == limit_gaussian:
-        warnings.warn("Problem occurs during the decomposition of a cluster. "
-                      "More than {0} spots seem to be necessary to reproduce "
-                      "the clustered region and decomposition was stopped "
-                      "early. Set a higher limit or check a potential "
-                      "artifact in the image.".format(limit_gaussian),
-                      UserWarning)
+                                   psf_z=None, psf_yx=psf_yx,
+                                   return_coord=True)
+
+        # format coordinates and ensure it is fitted within the spot image
+        y_max, x_max = image_spot.shape
+        coord_y = parameters[0] / voxel_size_yx
+        if coord_y < 0 or coord_y > y_max:
+            coord_y = coord[0]
+        else:
+            coord_y += bbox_low[0]
+        coord_x = parameters[1] / voxel_size_yx
+        if coord_x < 0 or coord_x > x_max:
+            coord_x = coord[1]
+        else:
+            coord_x += bbox_low[1]
+        new_coord = [coord_y, coord_x]
 
-    # TODO clip the image correctly before casting it
-    best_simulation = np.reshape(best_simulation, image_region.shape)
-    best_simulation = best_simulation.astype(image_region.dtype)
+    # if a spot is ill-conditioned, we simply keep its original coordinates
+    except RuntimeError:
+        new_coord = list(coord)
 
-    return image_region, best_simulation, positions_gaussian
+    return new_coord
diff --git a/bigfish/detection/utils.py b/bigfish/detection/utils.py
deleted file mode 100644
index 261fa47e..00000000
--- a/bigfish/detection/utils.py
+++ /dev/null
@@ -1,222 +0,0 @@
-# -*- coding: utf-8 -*-
-# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
-# License: BSD 3 clause
-
-"""
-Utility functions for bigfish.stack subpackage.
-"""
-
-import bigfish.stack as stack
-import numpy as np
-
-
-# TODO add function to calibrate psf
-# TODO add function to compute signal-to-noise ratio
-
-# ### Signal-to-Noise ratio ###
-
-def compute_snr(image, sigma, minimum_distance=1,
-                threshold_signal_detection=2000, neighbor_factor=3):
-    """Compute Signal-to-Noise ratio for each spot detected.
-
-    Parameters
-    ----------
-    image : np.ndarray, np.uint
-        Image with shape (z, y, x) or (y, x).
-    sigma : float or Tuple(float)
-        Sigma used for the gaussian filter (one for each dimension). If it's a
-        float, the same sigma is applied to every dimensions.
-    minimum_distance : int
-        Minimum distance (in number of pixels) between two local peaks.
-    threshold_signal_detection : float or int
-        A threshold to detect peaks. Considered as a relative threshold if
-        float.
-    neighbor_factor : int or float
-        The ratio between the radius of the neighborhood defining the noise
-        and the radius of the signal.
-
-    Returns
-    -------
-
-    """
-    # cast image in np.float, apply LoG filter and find local maximum
-    mask = log_lm(image, sigma, minimum_distance)
-
-    # apply a specific threshold to filter the detected spots and compute snr
-    l_snr = from_threshold_to_snr(image, sigma, mask,
-                                  threshold_signal_detection,
-                                  neighbor_factor)
-
-    return l_snr
-
-
-def from_threshold_to_snr(image, sigma, mask, threshold=2000,
-                          neighbor_factor=3):
-    """
-
-    Parameters
-    ----------
-    image : np.ndarray, np.uint
-        Image with shape (z, y, x) or (y, x).
-    sigma : float or Tuple(float)
-        Sigma used for the gaussian filter (one for each dimension). If it's a
-        float, the same sigma is applied to every dimensions.
-    mask : np.ndarray, bool
-        Mask with shape (z, y, x) or (y, x) indicating the local peaks.
-    threshold : float or int
-        A threshold to detect peaks. Considered as a relative threshold if
-        float.
-    neighbor_factor : int or float
-        The ratio between the radius of the neighborhood defining the noise
-        and the radius of the signal.
-
-    Returns
-    -------
-
-    """
-    # remove peak with a low intensity
-    if isinstance(threshold, float):
-        threshold *= image.max()
-    mask_ = (mask & (image > threshold))
-
-    # no spot detected
-    if mask_.sum() == 0:
-        return []
-
-    # we get the xy coordinate of the detected spot
-    spot_coordinates = np.nonzero(mask_)
-    spot_coordinates = np.column_stack(spot_coordinates)
-
-    # compute radius for the spot and the neighborhood
-    s = np.sqrt(image.ndim)
-    (z_radius, yx_radius) = (int(s * sigma[0]), int(s * sigma[1]))
-    (z_neigh, yx_neigh) = (int(s * sigma[0] * neighbor_factor),
-                           int(s * sigma[1] * neighbor_factor))
-
-    # we enlarge our mask to localize the complete signal and not just
-    # the peak
-    kernel_size_z = 2 * z_radius + 1
-    kernel_size_yx = 2 * yx_radius + 1
-    kernel_size = (kernel_size_z, kernel_size_yx, kernel_size_yx)
-    mask_ = ndi.maximum_filter(mask_, size=kernel_size,
-                               mode='constant')
-
-    # we define a binary matrix of noise
-    noise = image.astype(np.float64)
-    noise[mask_] = np.nan
-
-    l_snr = []
-    for i in range(spot_coordinates.shape[0]):
-        (z, y, x) = (spot_coordinates[i, 0],
-                     spot_coordinates[i, 1],
-                     spot_coordinates[i, 2])
-
-        max_z, max_y, max_x = image.shape
-        if (z_neigh <= z <= max_z - z_neigh - 1
-                and yx_neigh <= y <= max_y - yx_neigh - 1
-                and yx_neigh <= x <= max_x - yx_neigh - 1):
-            pass
-        else:
-            l_snr.append(np.nan)
-            continue
-
-        # extract local signal
-        local_signal = image[z - z_radius: z + z_radius + 1,
-                             y - yx_radius: y + yx_radius + 1,
-                             x - yx_radius: x + yx_radius + 1].copy()
-
-        # extract local noise
-        local_noise = noise[z - z_neigh: z + z_neigh + 1,
-                            y - yx_neigh: y + yx_neigh + 1,
-                            x - yx_neigh: x + yx_neigh + 1].copy()
-        local_noise[z_neigh - z_radius: z_neigh + z_radius + 1,
-                    yx_neigh - yx_radius: yx_neigh + yx_radius + 1,
-                    yx_neigh - yx_radius: yx_neigh + yx_radius + 1] = np.nan
-
-        # compute snr
-        snr = np.nanmean(local_signal) / np.nanstd(local_noise)
-        l_snr.append(snr)
-
-    return l_snr
-
-
-# ### Utilities ###
-
-def get_sigma(voxel_size_z=None, voxel_size_yx=100, psf_z=None, psf_yx=200):
-    """Compute the standard deviation of the PSF of the spots.
-
-    Parameters
-    ----------
-    voxel_size_z : int or float or None
-        Height of a voxel, along the z axis, in nanometer. If None, we
-        consider a 2-d PSF.
-    voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
-    psf_z : int or float or None
-        Theoretical size of the PSF emitted by a spot in the z plan,
-        in nanometer. If None, we consider a 2-d PSF.
-    psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
-
-    Returns
-    -------
-    sigma : Tuple[float]
-        Standard deviations in pixel of the PSF, one element per dimension.
-
-    """
-    # check parameters
-    stack.check_parameter(voxel_size_z=(int, float, type(None)),
-                          voxel_size_yx=(int, float),
-                          psf_z=(int, float, type(None)),
-                          psf_yx=(int, float))
-
-    # compute sigma
-    sigma_yx = psf_yx / voxel_size_yx
-
-    if voxel_size_z is None or psf_z is None:
-        return sigma_yx, sigma_yx
-
-    else:
-        sigma_z = psf_z / voxel_size_z
-        return sigma_z, sigma_yx, sigma_yx
-
-
-def get_radius(voxel_size_z=None, voxel_size_yx=100, psf_z=None, psf_yx=200):
-    """Approximate the radius of the detected spot.
-
-    We use the formula:
-
-        sqrt(ndim) * sigma
-
-    with ndim the number of dimension of the image and sigma the standard
-    deviation (in pixel) of the detected spot.
-
-    Parameters
-    ----------
-    voxel_size_z : int or float or None
-        Height of a voxel, along the z axis, in nanometer. If None, we
-        consider a 2-d spot.
-    voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
-    psf_z : int or float or None
-        Theoretical size of the PSF emitted by a spot in the z plan,
-        in nanometer. If None, we consider a 2-d spot.
-    psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
-
-    Returns
-    -------
-    radius : Tuple[float]
-        Radius in pixels of the detected spots, one element per dimension.
-
-    """
-    # compute sigma
-    sigma = get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
-
-    # compute radius
-    radius = [np.sqrt(len(sigma)) * sigma_ for sigma_ in sigma]
-    radius = tuple(radius)
-
-    return radius
diff --git a/bigfish/plot/__init__.py b/bigfish/plot/__init__.py
index d706aff6..06accb68 100644
--- a/bigfish/plot/__init__.py
+++ b/bigfish/plot/__init__.py
@@ -10,14 +10,18 @@
 from .plot_images import plot_images
 from .plot_images import plot_segmentation
 from .plot_images import plot_segmentation_boundary
+from .plot_images import plot_segmentation_diff
+
 from .plot_images import plot_detection
 from .plot_images import plot_reference_spot
 from .plot_images import plot_cell
 
 from .plot_quality import plot_sharpness
+from .plot_quality import plot_elbow
 
-from .plot_classification import plot_confusion_matrix
-from .plot_classification import plot_2d_projection
+from .utils import save_plot
+from .utils import get_minmax_values
+from .utils import create_colormap
 
 
 _images = [
@@ -25,15 +29,18 @@
     "plot_images",
     "plot_segmentation",
     "plot_segmentation_boundary",
+    "plot_segmentation_diff",
     "plot_detection",
     "plot_reference_spot",
     "plot_cell"]
 
-_classification = [
-    "plot_confusion_matrix",
-    "plot_2d_projection"]
-
 _quality = [
-    "plot_sharpness"]
+    "plot_sharpness",
+    "plot_elbow"]
+
+_utils = [
+    "save_plot",
+    "get_minmax_values",
+    "create_colormap"]
 
-__all__ = _images + _classification + _quality
+__all__ = _images + _quality + _utils
diff --git a/bigfish/plot/plot_classification.py b/bigfish/plot/plot_classification.py
deleted file mode 100644
index 56335230..00000000
--- a/bigfish/plot/plot_classification.py
+++ /dev/null
@@ -1,110 +0,0 @@
-# -*- coding: utf-8 -*-
-# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
-# License: BSD 3 clause
-
-"""
-Functions to plot results from classification model.
-"""
-
-import matplotlib.pyplot as plt
-import numpy as np
-
-from .utils import save_plot
-
-from sklearn.metrics import confusion_matrix
-
-
-def plot_confusion_matrix(y_true, y_pred, normalize=False, classes_num=None,
-                          classes_str=None, title=None, framesize=(8, 8),
-                          size_title=20, size_axes=15, path_output=None,
-                          ext="png"):
-    # TODO add documentation
-    # compute confusion matrix
-    cm = confusion_matrix(y_true=y_true, y_pred=y_pred, labels=classes_num)
-
-    # normalize confusion matrix
-    if normalize:
-        cm = cm.astype(np.float32)
-        mask = (cm != 0)
-        cm = np.divide(cm, cm.sum(axis=1)[:, np.newaxis],
-                       out=np.zeros_like(cm),
-                       where=mask)
-
-    # plot confusion matrix
-    fig, ax = plt.subplots(figsize=framesize)
-    frame = ax.imshow(cm, interpolation='nearest', cmap=plt.get_cmap("Blues"))
-
-    # colorbar
-    colorbar = ax.figure.colorbar(frame, ax=ax, fraction=0.0453, pad=0.05)
-    if normalize:
-        colorbar.ax.set_ylabel("Density", rotation=-90, va="bottom",
-                               fontweight="bold", fontsize=size_axes-5)
-    else:
-        colorbar.ax.set_ylabel("Frequency", rotation=-90, va="bottom",
-                               fontweight="bold", fontsize=size_axes-5)
-    # cax = divider.append_axes("right", size=width, pad=pad)
-
-    # set ticks
-    ax.set_xticks(np.arange(cm.shape[1]))
-    ax.set_yticks(np.arange(cm.shape[0]))
-    ax.set_xticks(np.arange(cm.shape[1] + 1) - .5, minor=True)
-    ax.set_yticks(np.arange(cm.shape[0] + 1) - .5, minor=True)
-    ax.grid(which="minor", color="white", linestyle='-', linewidth=3)
-    ax.tick_params(which="minor", bottom=False, left=False)
-    if classes_str is not None:
-        ax.set_xticklabels(classes_str, rotation=45, ha="right",
-                           rotation_mode="anchor", fontsize=size_axes-5)
-        ax.set_yticklabels(classes_str, fontsize=size_axes-5)
-
-    # title and axes labels
-    if title is not None:
-        ax.set_title(title, fontweight="bold", fontsize=size_title)
-    ax.set_xlabel("Predicted label", fontweight="bold", fontsize=size_axes)
-    ax.set_ylabel("True label", fontweight="bold", fontsize=size_axes)
-
-    # text annotations in the matrix
-    fmt = '.2f' if normalize else 'd'
-    threshold = np.nanmax(cm) / 2.
-    for i in range(cm.shape[0]):
-        for j in range(cm.shape[1]):
-            ax.text(j, i, format(cm[i, j], fmt), fontsize=size_axes-7,
-                    ha="center", va="center",
-                    color="white" if cm[i, j] > threshold else "black")
-
-    # show frame
-    fig.tight_layout()
-    save_plot(path_output, ext)
-    fig.show()
-
-    return
-
-
-def plot_2d_projection(x, y, labels_num, labels_str, colors, markers=None,
-                       title=None, framesize=(10, 10), size_data=50, alpha=0.8,
-                       size_title=20, size_axes=15, size_legend=15,
-                       path_output=None, ext="png"):
-    # TODO add documentation
-    # define markers
-    if markers is None:
-        markers = ["."] * len(labels_str)
-
-    # plot
-    plt.figure(figsize=framesize)
-    for i, label_num in enumerate(labels_num):
-        plt.scatter(x[y == label_num, 0], x[y == label_num, 1],
-                    s=size_data, c=colors[i], label=labels_str[i],
-                    marker=markers[i], alpha=alpha)
-
-    # text annotations
-    if title is not None:
-        plt.title(title, fontweight="bold", fontsize=size_title)
-    plt.xlabel("First component", fontweight="bold", fontsize=size_axes)
-    plt.ylabel("Second component", fontweight="bold", fontsize=size_axes)
-    plt.legend(prop={'size': size_legend})
-
-    # show frame
-    plt.tight_layout()
-    save_plot(path_output, ext)
-    plt.show()
-
-    return
diff --git a/bigfish/plot/plot_images.py b/bigfish/plot/plot_images.py
index ef6eb5fd..6a689a37 100644
--- a/bigfish/plot/plot_images.py
+++ b/bigfish/plot/plot_images.py
@@ -10,7 +10,7 @@
 
 import bigfish.stack as stack
 
-from .utils import save_plot, get_minmax_values
+from .utils import save_plot, get_minmax_values, create_colormap
 
 import matplotlib.pyplot as plt
 import numpy as np
@@ -45,7 +45,7 @@ def plot_yx(image, r=0, c=0, z=0, rescale=False, contrast=False,
     title : str
         Title of the image.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
     remove_frame : bool
         Remove axes and frame.
     path_output : str
@@ -56,9 +56,6 @@ def plot_yx(image, r=0, c=0, z=0, rescale=False, contrast=False,
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
-
     """
     # check parameters
     stack.check_array(image,
@@ -73,7 +70,8 @@ def plot_yx(image, r=0, c=0, z=0, rescale=False, contrast=False,
                           framesize=tuple,
                           remove_frame=bool,
                           path_output=(str, type(None)),
-                          ext=(str, list))
+                          ext=(str, list),
+                          show=bool)
 
     # get the 2-d image
     if image.ndim == 2:
@@ -112,11 +110,9 @@ def plot_yx(image, r=0, c=0, z=0, rescale=False, contrast=False,
     else:
         plt.close()
 
-    return
-
 
 def plot_images(images, rescale=False, contrast=False, titles=None,
-                framesize=(15, 5), remove_frame=True, path_output=None,
+                framesize=(15, 10), remove_frame=True, path_output=None,
                 ext="png", show=True):
     """Plot or subplot of 2-d images.
 
@@ -131,7 +127,7 @@ def plot_images(images, rescale=False, contrast=False, titles=None,
     titles : List[str]
         Titles of the subplots.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
     remove_frame : bool
         Remove axes and frame.
     path_output : str
@@ -142,8 +138,6 @@ def plot_images(images, rescale=False, contrast=False, titles=None,
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
 
     """
     # enlist image if necessary
@@ -244,13 +238,11 @@ def plot_images(images, rescale=False, contrast=False, titles=None,
     else:
         plt.close()
 
-    return
-
 
 # ### Segmentation plot ###
 
 def plot_segmentation(image, mask, rescale=False, contrast=False, title=None,
-                      framesize=(15, 5), remove_frame=True,
+                      framesize=(15, 10), remove_frame=True,
                       path_output=None, ext="png", show=True):
     """Plot result of a 2-d segmentation, with labelled instances if available.
 
@@ -267,7 +259,7 @@ def plot_segmentation(image, mask, rescale=False, contrast=False, title=None,
     title : str
         Title of the image.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
     remove_frame : bool
         Remove axes and frame.
     path_output : str
@@ -278,9 +270,6 @@ def plot_segmentation(image, mask, rescale=False, contrast=False, title=None,
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
-
     """
     # check parameters
     stack.check_array(image,
@@ -349,8 +338,6 @@ def plot_segmentation(image, mask, rescale=False, contrast=False, title=None,
     else:
         plt.close()
 
-    return
-
 
 def plot_segmentation_boundary(image, cell_label=None, nuc_label=None,
                                rescale=False, contrast=False, title=None,
@@ -373,7 +360,7 @@ def plot_segmentation_boundary(image, cell_label=None, nuc_label=None,
     title : str
         Title of the image.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
     remove_frame : bool
         Remove axes and frame.
     path_output : str
@@ -384,9 +371,6 @@ def plot_segmentation_boundary(image, cell_label=None, nuc_label=None,
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
-
     """
     # check parameters
     stack.check_array(image,
@@ -454,7 +438,109 @@ def plot_segmentation_boundary(image, cell_label=None, nuc_label=None,
     else:
         plt.close()
 
-    return
+
+def plot_segmentation_diff(image, mask_pred, mask_gt, rescale=False,
+                           contrast=False, title=None, framesize=(15, 10),
+                           remove_frame=True, path_output=None, ext="png",
+                           show=True):
+    """Plot segmentation results along with ground truth to compare.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.uint, np.int, np.float or bool
+        Image with shape (y, x).
+    mask_pred : np.ndarray, np.uint, np.int or np.float
+        Image with shape (y, x).
+    mask_gt : np.ndarray, np.uint, np.int or np.float
+        Image with shape (y, x).
+    rescale : bool
+        Rescale pixel values of the image (made by default in matplotlib).
+    contrast : bool
+        Contrast image.
+    title : str or None
+        Title of the plot.
+    framesize : tuple
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
+    remove_frame : bool
+        Remove axes and frame.
+    path_output : str or None
+        Path to save the image (without extension).
+    ext : str or List[str]
+        Extension used to save the plot. If it is a list of strings, the plot
+        will be saved several times.
+    show : bool
+        Show the figure or not.
+
+    """
+    # check parameters
+    stack.check_parameter(rescale=bool,
+                          contrast=bool,
+                          title=(str, type(None)),
+                          framesize=tuple,
+                          remove_frame=bool,
+                          path_output=(str, type(None)),
+                          ext=(str, list),
+                          show=bool)
+    stack.check_array(image,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64,
+                             np.float32, np.float64,
+                             bool])
+    stack.check_array(mask_pred,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int32, np.int64,
+                             np.float32, np.float64,
+                             bool])
+    stack.check_array(mask_gt,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int32, np.int64,
+                             np.float32, np.float64,
+                             bool])
+
+    # plot multiple images
+    fig, ax = plt.subplots(1, 3, figsize=framesize)
+
+    # image
+    if remove_frame:
+        ax[0].axis("off")
+    if not rescale and not contrast:
+        vmin, vmax = get_minmax_values(image)
+        ax[0].imshow(image, vmin=vmin, vmax=vmax)
+    elif rescale and not contrast:
+        ax[0].imshow(image)
+    else:
+        if image.dtype not in [np.int64, bool]:
+            image = stack.rescale(image, channel_to_stretch=0)
+        ax[0].imshow(image)
+    if title is None:
+        ax[0].set_title("", fontweight="bold", fontsize=10)
+    else:
+        ax[0].set_title(title, fontweight="bold", fontsize=10)
+
+    # build colormap
+    cmap = create_colormap()
+
+    # prediction
+    im_mask_pred = np.ma.masked_where(mask_pred == 0, mask_pred)
+    if remove_frame:
+        ax[1].axis("off")
+    ax[1].imshow(im_mask_pred, cmap=cmap)
+    ax[1].set_title("Prediction", fontweight="bold", fontsize=10)
+
+    # ground truth
+    im_mask_gt = np.ma.masked_where(mask_gt == 0, mask_gt)
+    if remove_frame:
+        ax[2].axis("off")
+    ax[2].imshow(im_mask_gt, cmap=cmap)
+    ax[2].set_title("Ground truth", fontweight="bold", fontsize=10)
+
+    plt.tight_layout()
+    if path_output is not None:
+        save_plot(path_output, ext)
+    if show:
+        plt.show()
+    else:
+        plt.close()
 
 
 # ### Detection plot ###
@@ -476,21 +562,21 @@ def plot_detection(image, spots, shape="circle", radius=3, color="red",
         list of arrays.
     shape : List[str] or str
         List of symbols used to localized the detected spots in the image,
-        among 'circle', 'square' or 'polygon'. One symbol per array in 'spots'.
-        If 'shape' is a string, the same symbol is used for every elements of
+        among `circle`, `square` or `polygon`. One symbol per array in `spots`.
+        If `shape` is a string, the same symbol is used for every elements of
         'spots'.
     radius : List[int or float] or int or float
         List of yx radii of the detected spots. One radius per array in
-        'spots'. If 'radius' is a scalar, the same value is applied for every
-        elements of 'spots'.
+        `spots`. If `radius` is a scalar, the same value is applied for every
+        elements of `spots`.
     color : List[str] or str
-        List of colors of the detected spots. One color per array in 'spots'.
-        If 'color' is a string, the same color is applied for every elements
-        of 'spots'.
+        List of colors of the detected spots. One color per array in `spots`.
+        If `color` is a string, the same color is applied for every elements
+        of `spots`.
     linewidth : List[int] or int
         List of widths or width of the border symbol. One integer per array
-        in 'spots'. If 'linewidth' is an integer, the same width is applied
-        for every elements of 'spots'.
+        in `spots`. If `linewidth` is an integer, the same width is applied
+        for every elements of `spots`.
     fill : List[bool] or bool
         List of boolean to fill the symbol the detected spots.
     rescale : bool
@@ -500,7 +586,7 @@ def plot_detection(image, spots, shape="circle", radius=3, color="red",
     title : str
         Title of the image.
     framesize : tuple
-        Size of the frame used to plot (plt.figure(figsize=framesize).
+        Size of the frame used to plot with ``plt.figure(figsize=framesize``.
     remove_frame : bool
         Remove axes and frame.
     path_output : str
@@ -511,9 +597,6 @@ def plot_detection(image, spots, shape="circle", radius=3, color="red",
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
-
     """
     # check parameters
     stack.check_array(image,
@@ -536,9 +619,9 @@ def plot_detection(image, spots, shape="circle", radius=3, color="red",
                           show=bool)
     if isinstance(spots, list):
         for spots_ in spots:
-            stack.check_array(spots_, ndim=2, dtype=np.int64)
+            stack.check_array(spots_, ndim=2, dtype=[np.int64, np.float64])
     else:
-        stack.check_array(spots, ndim=2, dtype=np.int64)
+        stack.check_array(spots, ndim=2, dtype=[np.int64, np.float64])
 
     # enlist and format parameters
     if not isinstance(spots, list):
@@ -626,8 +709,6 @@ def plot_detection(image, spots, shape="circle", radius=3, color="red",
     else:
         plt.close()
 
-    return
-
 
 def _define_patch(x, y, shape, radius, color, linewidth, fill):
     """Define a matplotlib.patches to plot.
@@ -639,7 +720,7 @@ def _define_patch(x, y, shape, radius, color, linewidth, fill):
     y : int or float
         Coordinate y for the patch center.
     shape : str
-        Shape of the patch to define (among 'circle', 'square' or 'polygon')
+        Shape of the patch to define (among `circle`, `square` or `polygon`)
     radius : int or float
         Radius of the patch.
     color : str
@@ -696,7 +777,7 @@ def plot_reference_spot(reference_spot, rescale=False, contrast=False,
     title : str
         Title of the image.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
     remove_frame : bool
         Remove axes and frame.
     path_output : str
@@ -707,9 +788,6 @@ def plot_reference_spot(reference_spot, rescale=False, contrast=False,
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
-
     """
     # check parameters
     stack.check_array(reference_spot,
@@ -756,8 +834,6 @@ def plot_reference_spot(reference_spot, rescale=False, contrast=False,
     else:
         plt.close()
 
-    return
-
 
 # ### Individual cell plot ###
 
@@ -807,7 +883,7 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
     contrast : bool
         Contrast image.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
     path_output : str or None
         Path to save the image (without extension).
     ext : str or List[str]
@@ -816,9 +892,6 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
-
     """
     if cell_coord is None and image is None:
         return
@@ -925,6 +998,14 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
                             fontweight="bold", fontsize=10)
         plt.tight_layout()
 
+        # output
+        if path_output is not None:
+            save_plot(path_output, ext)
+        if show:
+            plt.show()
+        else:
+            plt.close()
+
     # plot coordinate representation only
     elif cell_coord is not None and image is None:
         if remove_frame:
@@ -974,6 +1055,14 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
         if not remove_frame:
             plt.tight_layout()
 
+        # output
+        if path_output is not None:
+            save_plot(path_output, ext)
+        if show:
+            plt.show()
+        else:
+            plt.close()
+
     # plot original image only
     elif cell_coord is None and image is not None:
         plot_segmentation_boundary(
@@ -981,13 +1070,3 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
             rescale=rescale, contrast=contrast, title=title,
             framesize=framesize, remove_frame=remove_frame,
             path_output=path_output, ext=ext, show=show)
-
-    # output
-    if path_output is not None:
-        save_plot(path_output, ext)
-    if show:
-        plt.show()
-    else:
-        plt.close()
-
-    return
diff --git a/bigfish/plot/plot_quality.py b/bigfish/plot/plot_quality.py
index da394657..637adec0 100644
--- a/bigfish/plot/plot_quality.py
+++ b/bigfish/plot/plot_quality.py
@@ -7,6 +7,7 @@
 """
 
 import bigfish.stack as stack
+import bigfish.detection as detection
 
 import matplotlib.pyplot as plt
 import numpy as np
@@ -14,28 +15,33 @@
 from .utils import save_plot
 
 
+# ### Focus - sharpness ###
+
 def plot_sharpness(focus_measures, labels=None, title=None, framesize=(5, 5),
                    size_title=20, size_axes=15, size_legend=15,
                    path_output=None, ext="png", show=True):
-    """
+    """Plot focus measures of a 3-d image, at the z-slice level.
+
+    A measure of focus for each z-slice can be computed by averaging the
+    pixel-wise focus measure returned from :func:`bigfish.stack.compute_focus`.
 
     Parameters
     ----------
     focus_measures : np.ndarray or List[np.ndarray]
-        A list of 1-d array with the sharpness measure for each z-slices.
-    labels : List[str]
+        A list of 1-d arrays with the sharpness measure for each z-slices.
+    labels : List[str] or None
         List of labels for the different measures to compare.
-    title : str
+    title : str or None
         Title of the plot.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
     size_title : int
         Size of the title.
     size_axes : int
         Size of the axes label.
     size_legend : int
         Size of the legend.
-    path_output : str
+    path_output : str or None
         Path to save the image (without extension).
     ext : str or List[str]
         Extension used to save the plot. If it is a list of strings, the plot
@@ -43,11 +49,8 @@ def plot_sharpness(focus_measures, labels=None, title=None, framesize=(5, 5),
     show : bool
         Show the figure or not.
 
-    Returns
-    -------
-
     """
-    # enlist image if necessary
+    # enlist values if necessary
     if isinstance(focus_measures, np.ndarray):
         focus_measures = [focus_measures]
 
@@ -94,54 +97,87 @@ def plot_sharpness(focus_measures, labels=None, title=None, framesize=(5, 5),
     else:
         plt.close()
 
-    return
 
+# ### Elbow plot ###
 
-def plot_illumination_surface(illumination_surface, r=0, framesize=(15, 15),
-                              titles=None, path_output=None, ext="png"):
-    """Subplot the yx plan of the dimensions of an illumination surface for
-    all channels.
+def plot_elbow(images, voxel_size_z, voxel_size_yx, psf_z, psf_yx, title=None,
+               framesize=(5, 5), size_title=20, size_axes=15, size_legend=15,
+               path_output=None, ext="png", show=True):
+    """Plot the elbow curve that allows a automated spot detection.
 
     Parameters
     ----------
-    illumination_surface : np.ndarray, np.float
-        A 4-d tensor with shape (r, c, y, x) approximating the average
-        differential of illumination in our stack of images, for each channel
-        and each round.
-    r : int
-        Index of the round to keep.
+    images : List[np.ndarray]
+        List of images with shape (z, y, x) or (y, x). The same threshold is
+        applied to every images.
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, image is
+        considered in 2-d.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, image is considered in 2-d.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
+    title : str or None
+        Title of the plot.
     framesize : tuple
-        Size of the frame used to plot with 'plt.figure(figsize=framesize)'.
-    titles : List[str]
-        Titles of the subplots (one per channel).
-    path_output : str
+        Size of the frame used to plot with ``plt.figure(figsize=framesize)``.
+    size_title : int
+        Size of the title.
+    size_axes : int
+        Size of the axes label.
+    size_legend : int
+        Size of the legend.
+    path_output : str or None
         Path to save the image (without extension).
     ext : str or List[str]
         Extension used to save the plot. If it is a list of strings, the plot
         will be saved several times.
-
-    Returns
-    -------
+    show : bool
+        Show the figure or not.
 
     """
-    # TODO add title in the plot and remove axes
-    # TODO add parameter for vmin and vmax
-    # check tensor
-    stack.check_array(illumination_surface,
-                      ndim=4,
-                      dtype=[np.float32, np.float64])
+    # check parameters
+    stack.check_parameter(title=(str, list, type(None)),
+                          framesize=tuple,
+                          size_title=int,
+                          size_axes=int,
+                          size_legend=int,
+                          path_output=(str, type(None)),
+                          ext=(str, list),
+                          show=bool)
 
-    # get the number of channels
-    nb_channels = illumination_surface.shape[1]
+    # get candidate thresholds and spots count to plot the elbow curve
+    thresholds, count_spots, threshold = detection.get_elbow_values(
+        images=images,
+        voxel_size_z=voxel_size_z,
+        voxel_size_yx=voxel_size_yx,
+        psf_z=psf_z,
+        psf_yx=psf_yx)
 
     # plot
-    fig, ax = plt.subplots(1, nb_channels, sharex='col', figsize=framesize)
-    for i in range(nb_channels):
-        ax[i].imshow(illumination_surface[r, i, :, :])
-        if titles is not None:
-            ax[i].set_title(titles[i], fontweight="bold", fontsize=15)
-    plt.tight_layout()
-    save_plot(path_output, ext)
-    plt.show()
+    plt.figure(figsize=framesize)
+    plt.plot(thresholds, count_spots, c="#2c7bb6", lw=2)
+    if threshold is not None:
+        i_threshold = np.argmax(thresholds == threshold)
+        plt.scatter(threshold, count_spots[i_threshold],
+                    marker="D", c="#d7191c", s=60, label="Selected threshold")
 
-    return
+    # axes
+    if title is not None:
+        plt.title(title, fontweight="bold", fontsize=size_title)
+    plt.xlabel("Thresholds", fontweight="bold", fontsize=size_axes)
+    plt.ylabel("Number of mRNAs detected (log scale)",
+               fontweight="bold", fontsize=size_axes)
+    if threshold is not None:
+        plt.legend(prop={'size': size_legend})
+    plt.tight_layout()
+    if path_output is not None:
+        save_plot(path_output, ext)
+    if show:
+        plt.show()
+    else:
+        plt.close()
diff --git a/bigfish/plot/utils.py b/bigfish/plot/utils.py
index d71f8bc3..2c7a2042 100644
--- a/bigfish/plot/utils.py
+++ b/bigfish/plot/utils.py
@@ -21,9 +21,6 @@ def save_plot(path_output, ext):
         Extension used to save the plot. If it is a list of strings, the plot
         will be saved several times.
 
-    Returns
-    -------
-
     """
     # add extension at the end of the filename
     extension = "." + ext
@@ -48,8 +45,6 @@ def save_plot(path_output, ext):
         Warning("Plot is not saved because the extension is not valid: "
                 "{0}.".format(ext))
 
-    return
-
 
 def get_minmax_values(tensor):
     """Get the minimum and maximum value of the image according to its dtype.
@@ -81,3 +76,22 @@ def get_minmax_values(tensor):
         vmin, vmax = 0, 1
 
     return vmin, vmax
+
+
+def create_colormap():
+    """Create a shuffled colormap to display segmentation masks.
+
+    Returns
+    -------
+    colormap : ListedColormap object
+        Colormap for matplotlib.
+
+    """
+    import matplotlib.pyplot as plt
+
+    values = np.linspace(0, 1, 256)
+    np.random.shuffle(values)
+    colormap = plt.cm.colors.ListedColormap(plt.cm.YlGnBu(values))
+    colormap.set_bad(color='black')
+
+    return colormap
diff --git a/bigfish/segmentation/__init__.py b/bigfish/segmentation/__init__.py
index fd12e5fb..f91f0309 100644
--- a/bigfish/segmentation/__init__.py
+++ b/bigfish/segmentation/__init__.py
@@ -7,35 +7,62 @@
 nuclei and cells.
 """
 
-from .utils import label_instances
-from .utils import merge_labels
-from .utils import thresholding
-from .utils import clean_segmentation
-from .utils import compute_instances_mean_diameter
-from .utils import match_nuc_cell
-
-from .nuc_segmentation import remove_segmented_nuc
-
+from .cell_segmentation import unet_distance_edge_double
+from .cell_segmentation import apply_unet_distance_double
+from .cell_segmentation import from_distance_to_instances
 from .cell_segmentation import cell_watershed
 from .cell_segmentation import get_watershed_relief
 from .cell_segmentation import apply_watershed
 
+from .nuc_segmentation import unet_3_classes_nuc
+from .nuc_segmentation import apply_unet_3_classes
+from .nuc_segmentation import from_3_classes_to_instances
+from .nuc_segmentation import remove_segmented_nuc
 
-_utils = [
-    "label_instances",
-    "merge_labels",
-    "thresholding",
-    "clean_segmentation",
-    "compute_instances_mean_diameter",
-    "match_nuc_cell"]
+from .postprocess import label_instances
+from .postprocess import merge_labels
+from .postprocess import clean_segmentation
+from .postprocess import remove_disjoint
+from .postprocess import match_nuc_cell
 
-_nuc = [
-    "remove_segmented_nuc"]
+from .utils import thresholding
+from .utils import compute_mean_diameter
+from .utils import compute_mean_convexity_ratio
+from .utils import compute_surface_ratio
+from .utils import count_instances
+from .utils import resize_image
+from .utils import get_marge_padding
+from .utils import compute_image_standardization
 
-_cyt = [
+_cell = [
+    "unet_distance_edge_double",
+    "apply_unet_distance_double",
+    "from_distance_to_instances",
     "cell_watershed",
     "get_watershed_relief",
     "apply_watershed"]
 
+_nuc = [
+    "unet_3_classes_nuc",
+    "apply_unet_3_classes",
+    "from_3_classes_to_instances",
+    "remove_segmented_nuc"]
+
+_postprocess = [
+    "label_instances",
+    "merge_labels",
+    "clean_segmentation",
+    "remove_disjoint",
+    "match_nuc_cell"]
+
+_utils = [
+    "thresholding",
+    "compute_mean_diameter",
+    "compute_mean_convexity_ratio",
+    "compute_surface_ratio",
+    "count_instances",
+    "resize_image",
+    "get_marge_padding",
+    "compute_image_standardization"]
 
-__all__ = _utils + _nuc + _cyt
+__all__ = _cell + _nuc + _postprocess + _utils
diff --git a/bigfish/segmentation/cell_segmentation.py b/bigfish/segmentation/cell_segmentation.py
index 284c27e7..57266402 100644
--- a/bigfish/segmentation/cell_segmentation.py
+++ b/bigfish/segmentation/cell_segmentation.py
@@ -7,14 +7,267 @@
 """
 
 import bigfish.stack as stack
-from .utils import thresholding, clean_segmentation
+from .utils import thresholding
+from .utils import resize_image
+from .utils import get_marge_padding
+from .utils import compute_image_standardization
+from .postprocess import label_instances
+from .postprocess import clean_segmentation
 
 import numpy as np
 from scipy import ndimage as ndi
 
 from skimage.morphology import watershed
+from skimage.util import pad
 
 
+# ### Unet models ###
+
+def unet_distance_edge_double():
+    """Load a pretrained Unet model to predict foreground and a distance map
+    to edge from nucleus and cell images.
+
+    Returns
+    -------
+    model : ``tensorflow.keras.model`` object
+        Pretrained Unet model.
+
+    """
+    # import  deep_learning subpackage
+    import bigfish.deep_learning as dl
+
+    # load model
+    model = dl.load_pretrained_model("double", "distance_edge")
+
+    return model
+
+
+def apply_unet_distance_double(model, nuc, cell, nuc_label, target_size=None,
+                               test_time_augmentation=False):
+    """Segment cell with a pretrained model to predict distance map and  use
+    it with a watershed algorithm.
+
+    Parameters
+    ----------
+    model : ``tensorflow.keras.model`` object
+        Pretrained Unet model that predict distance to edges and cell surface.
+    nuc : np.ndarray, np.uint
+        Original nucleus image with shape (y, x).
+    cell : np.ndarray, np.uint
+        Original cell image to segment with shape (y, x).
+    nuc_label : np.ndarray, np.int64
+        Labelled nucleus image. Each nucleus is characterized by the same pixel
+        value.
+    target_size : int
+        Resize image before segmentation. A squared image is resize to
+        `target_size`. A rectangular image is resize such that its smaller
+        dimension equals `target_size`.
+    test_time_augmentation : bool
+        Apply test time augmentation or not. The image is augmented 8 times
+        and the final segmentation is the average result over these
+        augmentations.
+
+    Returns
+    -------
+    cell_label_pred : np.ndarray, np.int64
+        Labelled cell image. Each cell is characterized by the same pixel
+        value.
+
+    """
+    # check parameters
+    stack.check_parameter(target_size=(int, type(None)),
+                          test_time_augmentation=bool)
+    stack.check_array(nuc, ndim=2, dtype=[np.uint8, np.uint16])
+    stack.check_array(cell, ndim=2, dtype=[np.uint8, np.uint16])
+    stack.check_array(nuc_label, ndim=2, dtype=np.int64)
+
+    # get original shape
+    height, width = cell.shape
+
+    # resize cell image if necessary
+    if target_size is None:
+        # keep the original shape
+        nuc_to_process = nuc.copy()
+        cell_to_process = cell.copy()
+        nuc_label_to_process = nuc_label.copy()
+        new_height, new_width = height, width
+
+    else:
+        # target size should be multiple of 16
+        target_size -= 16
+
+        # we resize the images below the target size
+        ratio = target_size / min(height, width)
+        new_height = int(np.round(height * ratio))
+        new_width = int(np.round(width * ratio))
+        new_shape = (new_height, new_width)
+        nuc_to_process = resize_image(nuc, new_shape, "bilinear")
+        cell_to_process = resize_image(cell, new_shape, "bilinear")
+        nuc_label_to_process = nuc_label.copy()
+
+    # get padding marge to make it multiple of 16
+    marge_padding = get_marge_padding(new_height, new_width, x=16)
+    top, bottom = marge_padding[0]
+    left, right = marge_padding[1]
+    nuc_to_process = pad(nuc_to_process, marge_padding, mode='symmetric')
+    cell_to_process = pad(cell_to_process, marge_padding, mode='symmetric')
+
+    # standardize and cast cell image
+    nuc_to_process = compute_image_standardization(nuc_to_process)
+    nuc_to_process = nuc_to_process.astype(np.float32)
+    cell_to_process = compute_image_standardization(cell_to_process)
+    cell_to_process = cell_to_process.astype(np.float32)
+
+    # augment images
+    if test_time_augmentation:
+        nuc_to_process = stack.augment_8_times(nuc_to_process)
+        cell_to_process = stack.augment_8_times(cell_to_process)
+        n_augmentations = 8
+    else:
+        nuc_to_process = [nuc_to_process]
+        cell_to_process = [cell_to_process]
+        n_augmentations = 1
+
+    # loop over augmentations
+    predictions_cell = []
+    predictions_distance = []
+    for i in range(n_augmentations):
+
+        # get images
+        nuc_to_process_ = nuc_to_process[i]
+        nuc_to_process_ = nuc_to_process_[np.newaxis, :, :, np.newaxis]
+        cell_to_process_ = cell_to_process[i]
+        cell_to_process_ = cell_to_process_[np.newaxis, :, :, np.newaxis]
+
+        # make predictions
+        prediction = model.predict([nuc_to_process_, cell_to_process_])
+
+        # remove padding
+        if i in [0, 1, 2, 6]:
+            prediction_cell = prediction[1][0, top:-bottom, left:-right, 0]
+            prediction_distance = prediction[2][0, top:-bottom, left:-right, 0]
+        else:
+            prediction_cell = prediction[1][0, left:-right, top:-bottom, 0]
+            prediction_distance = prediction[2][0, left:-right, top:-bottom, 0]
+
+        # resize predictions back taking into account a potential deformation
+        # from the image augmentation
+        if target_size is not None:
+            if i in [0, 1, 2, 6]:
+                prediction_cell = resize_image(
+                    prediction_cell, (height, width), "bilinear")
+                prediction_distance = resize_image(
+                    prediction_distance, (height, width), "bilinear")
+            else:
+                prediction_cell = resize_image(
+                    prediction_cell, (width, height), "bilinear")
+                prediction_distance = resize_image(
+                    prediction_distance, (width, height), "bilinear")
+
+        # store predictions
+        predictions_cell.append(prediction_cell)
+        predictions_distance.append(prediction_distance)
+
+    # reversed image augmentation
+    if test_time_augmentation:
+        predictions_cell = stack.augment_8_times_reversed(predictions_cell)
+        predictions_distance = stack.augment_8_times_reversed(
+            predictions_distance)
+        mean_prediction_cell = np.mean(predictions_cell, axis=0)
+        mean_prediction_distance = np.mean(predictions_distance, axis=0)
+    else:
+        mean_prediction_cell = predictions_cell[0]
+        mean_prediction_distance = predictions_distance[0]
+
+    # inverse and format distance map
+    min_ = mean_prediction_distance.min()
+    max_ = max(1, mean_prediction_distance.max())
+    mean_prediction_distance -= min_
+    mean_prediction_distance /= max_
+    mean_prediction_distance = 1 - mean_prediction_distance
+    mean_prediction_distance = np.clip(mean_prediction_distance, 0, 1)
+    mean_prediction_distance = stack.cast_img_uint16(mean_prediction_distance,
+                                                     catch_warning=True)
+
+    # postprocess predictions
+    _, cell_label_pred = from_distance_to_instances(
+        label_x_nuc=nuc_label_to_process,
+        label_2_cell=mean_prediction_cell,
+        label_distance=mean_prediction_distance,
+        compute_nuc_label=False)
+
+    return cell_label_pred
+
+
+def from_distance_to_instances(label_x_nuc, label_2_cell, label_distance,
+                               nuc_3_classes=False, compute_nuc_label=False):
+    """Extract instance labels from a distance map and a binary surface
+    prediction with a watershed algorithm.
+
+    Parameters
+    ----------
+    label_x_nuc : np.ndarray, np.float32
+        Model prediction about the nucleus surface (and boundaries), with shape
+        (y, x, 1) or (y, x, 3).
+    label_2_cell : np.ndarray, np.float32
+        Model prediction about cell surface, with shape (y, x, 1).
+    label_distance : np.ndarray, np.uint16
+        Model prediction about the distance to edges, with shape (y, x, 1).
+    nuc_3_classes : bool
+        Nucleus image input is an output from a 3-classes Unet.
+    compute_nuc_label : bool
+        Extract nucleus instance labels.
+
+    Returns
+    -------
+    nuc_label : np.ndarray, np.int64
+        Labelled nucleus image. Each nucleus is characterized by the same pixel
+        value.
+    cell_label : np.ndarray, np.int64
+        Labelled cell image. Each cell is characterized by the same pixel
+        value.
+
+    """
+    # check parameters
+    stack.check_parameter(nuc_3_classes=bool,
+                          compute_nuc_label=bool)
+    stack.check_array(label_x_nuc, ndim=2, dtype=[np.float32, np.int64])
+    stack.check_array(label_2_cell, ndim=2, dtype=[np.float32])
+    stack.check_array(label_distance, ndim=2, dtype=[np.uint16])
+
+    # get nuclei labels
+    if nuc_3_classes and compute_nuc_label:
+        label_3_nuc = np.argmax(label_x_nuc, axis=-1)
+        mask_nuc = label_3_nuc > 1
+        nuc_label = label_instances(mask_nuc)
+        nuc_label = nuc_label.astype(np.float64)
+        nuc_label = stack.dilation_filter(
+            nuc_label, kernel_shape="disk", kernel_size=1)
+        nuc_label = nuc_label.astype(np.int64)
+        mask_nuc = nuc_label > 0
+    elif not nuc_3_classes and compute_nuc_label:
+        mask_nuc = label_x_nuc > 0.5
+        nuc_label = label_instances(mask_nuc)
+    else:
+        nuc_label = label_x_nuc.copy()
+        mask_nuc = nuc_label > 0
+
+    # get cells surfaces
+    mask_cell = label_2_cell > 0.5
+    mask_cell |= mask_nuc
+
+    # apply watershed algorithm
+    cell_label = watershed(label_distance, markers=nuc_label, mask=mask_cell)
+
+    # cast labels in int64
+    nuc_label = nuc_label.astype(np.int64)
+    cell_label = cell_label.astype(np.int64)
+
+    return nuc_label, cell_label
+
+
+# ### Watershed ###
+
 def cell_watershed(image, nuc_label, threshold, alpha=0.8):
     """Apply watershed algorithm to segment cell instances.
 
@@ -48,6 +301,7 @@ def cell_watershed(image, nuc_label, threshold, alpha=0.8):
     relief = get_watershed_relief(image, nuc_label, alpha)
 
     # TODO improve cell mask methods
+    # TODO add options for clean_segmentation
     # build cells mask
     if image.ndim == 3:
         image_2d = stack.maximum_projection(image)
diff --git a/bigfish/segmentation/nuc_segmentation.py b/bigfish/segmentation/nuc_segmentation.py
index d72a64ba..c8dda8b8 100644
--- a/bigfish/segmentation/nuc_segmentation.py
+++ b/bigfish/segmentation/nuc_segmentation.py
@@ -6,31 +6,208 @@
 Class and functions to segment nucleus.
 """
 
-from bigfish import stack
-from .utils import clean_segmentation
-
 import numpy as np
 
+import bigfish.stack as stack
+from .utils import resize_image
+from .utils import get_marge_padding
+from .utils import compute_image_standardization
+from .postprocess import label_instances
+from .postprocess import clean_segmentation
+
 from skimage.morphology.selem import disk
 from skimage.morphology import reconstruction
+from skimage.util import pad
+
+
+# ### Unet model ###
+
+def unet_3_classes_nuc():
+    """Load a pretrained Unet model to predict 3 classes from nucleus images:
+    background, edge and foreground.
+
+    Returns
+    -------
+    model : ``tensorflow.keras.model`` object
+        Pretrained Unet model.
+
+    """
+    # import  deep_learning subpackage
+    import bigfish.deep_learning as dl
+
+    # load model
+    model = dl.load_pretrained_model("nuc", "3_classes")
+
+    return model
+
+
+def apply_unet_3_classes(model, image, target_size=None,
+                         test_time_augmentation=False):
+    """Segment image with a 3-classes trained model.
+
+    Parameters
+    ----------
+     model : ``tensorflow.keras.model`` object
+        Pretrained Unet model that predicts 3 classes from nucleus or cell
+        images (background, edge and foreground).
+    image : np.ndarray, np.uint
+        Original image to segment with shape (y, x).
+    target_size : int
+        Resize image before segmentation. A squared image is resize to
+        `target_size`. A rectangular image is resize such that its smaller
+        dimension equals `target_size`.
+    test_time_augmentation : bool
+        Apply test time augmentation or not. The image is augmented 8 times
+        and the final segmentation is the average result over these
+        augmentations.
+
+    Returns
+    -------
+    image_label_pred : np.ndarray, np.int64
+        Labelled image. Each instance is characterized by the same pixel value.
+
+    """
+    # check parameters
+    stack.check_parameter(target_size=(int, type(None)),
+                          test_time_augmentation=bool)
+    stack.check_array(image, ndim=2, dtype=[np.uint8, np.uint16])
+
+    # get original shape
+    height, width = image.shape
+
+    # resize image if necessary
+    if target_size is None:
+        # keep the original shape
+        image_to_process = image.copy()
+        new_height, new_width = height, width
+
+    else:
+        # target size should be multiple of 16
+        target_size -= 16
+
+        # we resize the images below the target size
+        ratio = target_size / min(height, width)
+        new_height = int(np.round(height * ratio))
+        new_width = int(np.round(width * ratio))
+        new_shape = (new_height, new_width)
+        image_to_process = resize_image(image, new_shape, "bilinear")
+
+    # get padding marge to make it multiple of 16
+    marge_padding = get_marge_padding(new_height, new_width, x=16)
+    top, bottom = marge_padding[0]
+    left, right = marge_padding[1]
+    image_to_process = pad(image_to_process, marge_padding, mode='symmetric')
+
+    # standardize and cast image
+    image_to_process = compute_image_standardization(image_to_process)
+    image_to_process = image_to_process.astype(np.float32)
+
+    # augment images
+    if test_time_augmentation:
+        image_to_process = stack.augment_8_times(image_to_process)
+        n_augmentations = 8
+    else:
+        image_to_process = [image_to_process]
+        n_augmentations = 1
+
+    # loop over augmentations
+    predictions_augmented = []
+    for i in range(n_augmentations):
+
+        # get images
+        image_to_process_ = image_to_process[i]
+        image_to_process_ = image_to_process_[np.newaxis, :, :, np.newaxis]
+
+        # make predictions
+        prediction = model.predict(image_to_process_)
+
+        # remove padding
+        if i in [0, 1, 2, 6]:
+            prediction = prediction[0, top:-bottom, left:-right, :]
+        else:
+            prediction = prediction[0, left:-right, top:-bottom, :]
+
+        # resize predictions back taking into account a potential deformation
+        # from the image augmentation
+        if target_size is not None:
+            if i in [0, 1, 2, 6]:
+                prediction = resize_image(
+                    prediction, (height, width), "bilinear")
+            else:
+                prediction = resize_image(
+                    prediction, (width, height), "bilinear")
+
+        # store predictions
+        predictions_augmented.append(prediction)
+
+    # reversed image augmentation
+    if test_time_augmentation:
+        predictions = stack.augment_8_times_reversed(predictions_augmented)
+        mean_prediction = np.mean(predictions, axis=0)
+    else:
+        mean_prediction = predictions_augmented[0]
+
+    # postprocess predictions
+    image_label_pred = from_3_classes_to_instances(mean_prediction)
+
+    return image_label_pred
+
+
+def from_3_classes_to_instances(label_3_classes):
+    """Extract instance labels from 3-classes Unet output.
+
+    Parameters
+    ----------
+    label_3_classes : np.ndarray, np.float32
+        Model prediction about the nucleus surface and boundaries, with shape
+        (y, x, 3).
+
+    Returns
+    -------
+    label : np.ndarray, np.int64
+        Labelled image. Each instance is characterized by the same pixel value.
+
+    """
+    # check parameters
+    stack.check_array(label_3_classes, ndim=3, dtype=[np.float32])
+
+    # get classes indices
+    label_3_classes = np.argmax(label_3_classes, axis=-1)
+
+    # keep foreground predictions
+    mask = label_3_classes > 1
+
+    # instantiate each individual foreground surface predicted
+    label = label_instances(mask)
+
+    # dilate label
+    label = label.astype(np.float64)
+    label = stack.dilation_filter(label, kernel_shape="disk", kernel_size=1)
+    label = label.astype(np.int64)
+
+    return label
+
 
+# ### Utility functions ###
 
 def remove_segmented_nuc(image, nuc_mask, size_nuclei=2000):
     """Remove the nuclei we have already segmented in an image.
 
-    1) We start from the segmented nuclei with a light dilation. The missed
-    nuclei and the background are set to 0 and removed from the original image.
-    2) We reconstruct the missing nuclei by small dilation. As we used the
-    original image to set the maximum allowed value at each pixel, the
-    background pixels remain unchanged. However, pixels from the missing
-    nuclei are partially reconstructed by the dilation. The reconstructed
-    image only differs from the original one where the nuclei have been missed.
-    3) We subtract the reconstructed image from the original one.
-    4) From the few missing nuclei kept and restored, we build a binary mask
-    (dilation, small object removal).
-    5) We apply this mask to the original image to get the original pixel
-    intensity of the missing nuclei.
-    6) We remove pixels with a too low intensity.
+    #. We start from the segmented nuclei with a light dilation. The missed
+       nuclei and the background are set to 0 and removed from the original
+       image.
+    #. We reconstruct the missing nuclei by small dilation. As we used the
+       original image to set the maximum allowed value at each pixel, the
+       background pixels remain unchanged. However, pixels from the missing
+       nuclei are partially reconstructed by the dilation. The reconstructed
+       image only differs from the original one where the nuclei have been
+       missed.
+    #. We subtract the reconstructed image from the original one.
+    #. From the few missing nuclei kept and restored, we build a binary mask
+       (dilation, small object removal).
+    #. We apply this mask to the original image to get the original pixel
+       intensity of the missing nuclei.
+    #. We remove pixels with a too low intensity.
 
     Parameters
     ----------
diff --git a/bigfish/segmentation/postprocess.py b/bigfish/segmentation/postprocess.py
new file mode 100644
index 00000000..02e95f72
--- /dev/null
+++ b/bigfish/segmentation/postprocess.py
@@ -0,0 +1,430 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Postprocessing functions functions for bigfish.segmentation subpackage.
+"""
+
+import bigfish.stack as stack
+
+import numpy as np
+from scipy import ndimage as ndi
+
+from skimage.measure import label
+from skimage.morphology import remove_small_objects
+
+
+# TODO make functions compatible with different type of integers
+
+# ### Labelled images ###
+
+def label_instances(image_binary):
+    """Count and label the different instances previously segmented in an
+    image.
+
+    Parameters
+    ----------
+    image_binary : np.ndarray, bool
+        Binary segmented image with shape (y, x).
+
+    Returns
+    -------
+    image_label : np.ndarray, np.int64
+        Labelled image. Each instance is characterized by the same pixel value.
+
+    """
+    # check parameters
+    stack.check_array(image_binary, ndim=2, dtype=bool)
+
+    # label instances
+    image_label = label(image_binary)
+
+    return image_label
+
+
+def merge_labels(image_label_1, image_label_2):
+    """Combine two partial labels of the same image.
+
+    To prevent merging conflict, labels should not be rescale.
+
+    Parameters
+    ----------
+    image_label_1 : np.ndarray, np.int64
+        Labelled image with shape (y, x).
+    image_label_2 : np.ndarray, np.int64
+        Labelled image with shape (y, x).
+
+    Returns
+    -------
+    image_label : np.ndarray, np.int64
+        Labelled image with shape (y, x).
+
+    """
+    # check parameters
+    stack.check_array(image_label_1, ndim=2, dtype=np.int64)
+    stack.check_array(image_label_2, ndim=2, dtype=np.int64)
+
+    # count number of instances
+    nb_instances_1 = image_label_1.max()
+    nb_instances_2 = image_label_2.max()
+    nb_instances = nb_instances_1 + nb_instances_2
+
+    # check if labels can be merged
+    if nb_instances > np.iinfo(np.int64).max:
+        raise ValueError("Labels can not be merged. There are too many "
+                         "instances for a 64 bit image, labels could overlap.")
+
+    # merge labels
+    image_label_2[image_label_2 > 0] += image_label_1
+    image_label = np.maximum(image_label_1, image_label_2)
+
+    return image_label
+
+
+# ### Basic segmentation ###
+
+def clean_segmentation(image, small_object_size=None, fill_holes=False,
+                       smoothness=None, delimit_instance=False):
+    """Clean segmentation results (binary masks or integer labels).
+
+    Parameters
+    ----------
+    image : np.ndarray, np.int64 or bool
+        Labelled or masked image with shape (y, x).
+    small_object_size : int or None
+        Areas with a smaller surface (in pixels) are removed.
+    fill_holes : bool
+        Fill holes within a labelled or masked area.
+    smoothness : int or None
+        Radius of a median kernel filter. The higher the smoother instance
+        boundaries are.
+    delimit_instance : bool
+        Delimit clearly instances boundaries by preventing contact between each
+        others.
+
+    Returns
+    -------
+    image_cleaned : np.ndarray, np.int64 or bool
+        Cleaned image with shape (y, x).
+
+    """
+    # check parameters
+    stack.check_array(image, ndim=2, dtype=[np.int64, bool])
+    stack.check_parameter(small_object_size=(int, type(None)),
+                          fill_holes=bool,
+                          smoothness=(int, type(None)),
+                          delimit_instance=bool)
+
+    # initialize cleaned image
+    image_cleaned = image.copy()
+
+    # remove small object
+    if small_object_size is not None:
+        image_cleaned = _remove_small_area(image_cleaned, small_object_size)
+
+    # fill holes
+    if fill_holes:
+        image_cleaned = _fill_hole(image_cleaned)
+
+    if smoothness:
+        image_cleaned = _smooth_instance(image_cleaned, smoothness)
+
+    if delimit_instance:
+        image_cleaned = _delimit_instance(image_cleaned)
+
+    return image_cleaned
+
+
+def _remove_small_area(image, min_size):
+    """Remove segmented areas with a small surface.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.int64 or bool
+        Labelled or masked image with shape (y, x).
+    min_size : int
+        Areas with a smaller surface (in pixels) are removed.
+
+    Returns
+    -------
+    image_cleaned : np.ndarray, np.int64 or bool
+        Cleaned image with shape (y, x).
+
+    """
+    # remove small object
+    image_cleaned = remove_small_objects(image, min_size=min_size)
+
+    return image_cleaned
+
+
+def _fill_hole(image):
+    """Fill holes within the segmented areas.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.int64 or bool
+        Labelled or masked image with shape (y, x).
+
+    Returns
+    -------
+    image_cleaned : np.ndarray, np.int64 or bool
+        Cleaned image with shape (y, x).
+
+    """
+    # fill holes in a binary mask
+    if image.dtype == bool:
+        image_cleaned = ndi.binary_fill_holes(image)
+
+    # fill holes in a labelled image
+    else:
+        image_cleaned = np.zeros_like(image)
+        for i in range(1, image.max() + 1):
+            image_binary = image == i
+            image_binary = ndi.binary_fill_holes(image_binary)
+            image_cleaned[image_binary] = i
+
+    return image_cleaned
+
+
+def _smooth_instance(image, radius):
+    """Apply a median filter to smooth instance boundaries.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.int64 or bool
+        Labelled or masked image with shape (y, x).
+    radius : int
+        Radius of the kernel for the median filter. The higher the smoother.
+
+    Returns
+    -------
+    image_cleaned : np.ndarray, np.int64 or bool
+        Cleaned image with shape (y, x).
+
+    """
+    # smooth instance boundaries for a binary mask
+    if image.dtype == bool:
+        image_cleaned = image.astype(np.uint8)
+        image_cleaned = stack.median_filter(image_cleaned, "disk", radius)
+        image_cleaned = image_cleaned.astype(bool)
+
+    # smooth instance boundaries for a labelled image
+    else:
+        if image.max() <= 65535 and image.min() >= 0:
+            image_cleaned = image.astype(np.uint16)
+            image_cleaned = stack.median_filter(image_cleaned, "disk", radius)
+            image_cleaned = image_cleaned.astype(np.int64)
+        else:
+            raise ValueError("Segmentation boundaries can't be smoothed "
+                             "because more than 65535 has been detected in "
+                             "the image. Smoothing is performed with 16-bit "
+                             "unsigned integer images.")
+
+    return image_cleaned
+
+
+def _delimit_instance(image):
+    """Subtract an eroded image to a dilated one in order to prevent
+    boundaries contact.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.int64 or bool
+        Labelled or masked image with shape (y, x).
+
+    Returns
+    -------
+    image_cleaned : np.ndarray, np.int64 or bool
+        Cleaned image with shape (y, x).
+
+    """
+    # handle 64 bit integer
+    original_dtype = image.dtype
+    if image.dtype == np.int64:
+        image = image.astype(np.float64)
+
+    # erode-dilate mask
+    image_dilated = stack.dilation_filter(image, "disk", 1)
+    image_eroded = stack.erosion_filter(image, "disk", 1)
+    if original_dtype == bool:
+        borders = image_dilated & ~image_eroded
+        image_cleaned = image.copy()
+        image_cleaned[borders] = False
+    else:
+        borders = image_dilated - image_eroded
+        image_cleaned = image.copy()
+        image_cleaned[borders > 0] = 0
+        image_cleaned = image_cleaned.astype(original_dtype)
+
+    return image_cleaned
+
+
+def remove_disjoint(image):
+    """For each instances with disconnected parts, keep the larger one.
+
+    Parameters
+    ----------
+    image : np.ndarray, np.int or np.uint
+        Labelled image with shape (y, x).
+
+    Returns
+    -------
+    image_cleaned : np.ndarray, np.int or np.uint
+        Cleaned image with shape (y, x).
+
+    """
+    # check parameters
+    stack.check_array(image,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64])
+
+    # initialize cleaned labels
+    image_cleaned = np.zeros_like(image)
+
+    # loop over instances
+    max_label = image.max()
+    for i in range(1, max_label + 1):
+
+        # get instance mask
+        mask = image == i
+
+        # check if an instance is labelled with this value
+        if mask.sum() == 0:
+            continue
+
+        # get an index for each disconnected part of the instance
+        labelled_mask = label(mask)
+        indices = sorted(list(set(labelled_mask.ravel())))
+        if 0 in indices:
+            indices = indices[1:]
+
+        # keep the largest part of the instance
+        max_area = 0
+        mask_instance = None
+        for j in indices:
+            mask_part_j = labelled_mask == j
+            area_j = mask_part_j.sum()
+            if area_j > max_area:
+                max_area = area_j
+                mask_instance = mask_part_j
+
+        # add instance in the final label
+        image_cleaned[mask_instance] = i
+
+    return image_cleaned
+
+
+# ### Nuclei-cells matching
+
+def match_nuc_cell(nuc_label, cell_label, single_nuc, cell_alone):
+    """Match each nucleus instance with the most overlapping cell instance.
+
+    Parameters
+    ----------
+    nuc_label : np.ndarray, np.int or np.uint
+        Labelled image of nuclei with shape (y, x).
+    cell_label : np.ndarray, np.int or np.uint
+        Labelled image of cells with shape (y, x).
+    single_nuc : bool
+        Authorized only one nucleus in a cell.
+    cell_alone : bool
+        Authorized cell without nucleus.
+
+    Returns
+    -------
+    new_nuc_label : np.ndarray, np.int or np.uint
+        Labelled image of nuclei with shape (y, x).
+    new_cell_label : np.ndarray, np.int or np.uint
+        Labelled image of cells with shape (y, x).
+
+    """
+    # check parameters
+    stack.check_array(nuc_label,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(cell_label,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64])
+
+    # initialize new labelled images
+    new_nuc_label = np.zeros_like(nuc_label)
+    new_cell_label = np.zeros_like(cell_label)
+    remaining_cell_label = cell_label.copy()
+
+    # loop over nuclei
+    i_instance = 1
+    max_nuc_label = nuc_label.max()
+    for i_nuc in range(1, max_nuc_label + 1):
+
+        # get nuc mask
+        nuc_mask = nuc_label == i_nuc
+
+        # check if a nucleus is labelled with this value
+        if nuc_mask.sum() == 0:
+            continue
+
+        # check if a cell is labelled with this value
+        i_cell = _get_most_frequent_value(cell_label[nuc_mask])
+        if i_cell == 0:
+            continue
+
+        # get cell mask
+        cell_mask = cell_label == i_cell
+
+        # ensure nucleus is totally included in cell
+        cell_mask |= nuc_mask
+        cell_label[cell_mask] = i_cell
+        remaining_cell_label[cell_mask] = i_cell
+
+        # assign cell and nucleus
+        new_nuc_label[nuc_mask] = i_instance
+        new_cell_label[cell_mask] = i_instance
+        i_instance += 1
+
+        # remove pixel already assigned
+        remaining_cell_label[cell_mask] = 0
+
+        # if one nucleus per cell only, we remove the cell as candidate
+        if single_nuc:
+            cell_label[cell_mask] = 0
+
+    # if only cell with nucleus are authorized we stop here
+    if not cell_alone:
+        return new_nuc_label, new_cell_label
+
+    # loop over remaining cells
+    max_remaining_cell_label = remaining_cell_label.max()
+    for i_cell in range(1, max_remaining_cell_label + 1):
+
+        # get cell mask
+        cell_mask = remaining_cell_label == i_cell
+
+        # check if a cell is labelled with this value
+        if cell_mask.sum() == 0:
+            continue
+
+        # add cell in the result
+        new_cell_label[cell_mask] = i_instance
+        i_instance += 1
+
+    return new_nuc_label, new_cell_label
+
+
+def _get_most_frequent_value(array):
+    """Count the most frequent value in a array.
+
+    Parameters
+    ----------
+    array : np.ndarray, np.uint or np.int
+        Array-like object.
+
+    Returns
+    -------
+    value : int
+        Most frequent integer in the array.
+
+    """
+    value = np.argmax(np.bincount(array))
+
+    return value
diff --git a/bigfish/segmentation/utils.py b/bigfish/segmentation/utils.py
index 3b0f4896..bf3e4482 100644
--- a/bigfish/segmentation/utils.py
+++ b/bigfish/segmentation/utils.py
@@ -9,80 +9,14 @@
 import bigfish.stack as stack
 
 import numpy as np
-from scipy import ndimage as ndi
 
-from skimage.measure import label, regionprops
-from skimage.morphology import remove_small_objects
+from skimage.measure import regionprops
+from skimage.transform import resize
 
 
 # TODO make functions compatible with different type of integers
 
-# ### Labelled images ###
-
-def label_instances(image_binary):
-    """Count and label the different instances previously segmented in an
-    image.
-
-    Parameters
-    ----------
-    image_binary : np.ndarray, bool
-        Binary segmented image with shape (y, x).
-
-    Returns
-    -------
-    image_label : np.ndarray, np.int64
-        Labelled image. Each instance is characterized by the same pixel value.
-
-    """
-    # check parameters
-    stack.check_array(image_binary, ndim=2, dtype=bool)
-
-    # label instances
-    image_label = label(image_binary)
-
-    return image_label
-
-
-def merge_labels(image_label_1, image_label_2):
-    """Combine two partial labels of the same image.
-
-    To prevent merging conflict, labels should not be rescale.
-
-    Parameters
-    ----------
-    image_label_1 : np.ndarray, np.int64
-        Labelled image with shape (y, x).
-    image_label_2 : np.ndarray, np.int64
-        Labelled image with shape (y, x).
-
-    Returns
-    -------
-    image_label : np.ndarray, np.int64
-        Labelled image with shape (y, x).
-
-    """
-    # check parameters
-    stack.check_array(image_label_1, ndim=2, dtype=np.int64)
-    stack.check_array(image_label_2, ndim=2, dtype=np.int64)
-
-    # count number of instances
-    nb_instances_1 = image_label_1.max()
-    nb_instances_2 = image_label_2.max()
-    nb_instances = nb_instances_1 + nb_instances_2
-
-    # check if labels can be merged
-    if nb_instances > np.iinfo(np.int64).max:
-        raise ValueError("Labels can not be merged. There are too many "
-                         "instances for a 64 bit image, labels could overlap.")
-
-    # merge labels
-    image_label_2[image_label_2 > 0] += image_label_1
-    image_label = np.maximum(image_label_1, image_label_2)
-
-    return image_label
-
-
-# ### Basic segmentation ###
+# ### Thresholding method ###
 
 def thresholding(image, threshold):
     """Segment a 2-d image to discriminate object from background applying a
@@ -112,285 +46,249 @@ def thresholding(image, threshold):
     return image_segmented
 
 
-def clean_segmentation(image, small_object_size=None, fill_holes=False,
-                       smoothness=None, delimit_instance=False):
-    """Clean segmentation results (binary masks or integer labels).
+# ### Instances measures ###
+
+def compute_mean_diameter(image_label):
+    """Compute the averaged size of the segmented instances.
+
+    For each instance, we compute the diameter of an object with an equivalent
+    surface. Then, we average the diameters.
 
     Parameters
     ----------
-    image : np.ndarray, np.int64 or bool
-        Labelled or masked image with shape (y, x).
-    small_object_size : int or None
-        Areas with a smaller surface (in pixels) are removed.
-    fill_holes : bool
-        Fill holes within a labelled or masked area.
-    smoothness : int or None
-        Radius of a median kernel filter. The higher the smoother instance
-        boundaries are.
-    delimit_instance : bool
-        Delimit clearly instances boundaries by preventing contact between each
-        others.
+    image_label : np.ndarray, np.int or np.uint
+        Labelled image with shape (y, x).
 
     Returns
     -------
-    image_cleaned : np.ndarray, np.int64 or bool
-        Cleaned image with shape (y, x).
+    mean_diameter : float
+        Averaged size of the segmented instances.
 
     """
     # check parameters
-    stack.check_array(image, ndim=2, dtype=[np.int64, bool])
-    stack.check_parameter(small_object_size=(int, type(None)),
-                          fill_holes=bool,
-                          smoothness=(int, type(None)),
-                          delimit_instance=bool)
-
-    # initialize cleaned image
-    image_cleaned = image.copy()
-
-    # remove small object
-    if small_object_size is not None:
-        image_cleaned = _remove_small_area(image_cleaned, small_object_size)
+    stack.check_array(image_label,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64])
 
-    # fill holes
-    if fill_holes:
-        image_cleaned = _fill_hole(image_cleaned)
+    # compute properties of the segmented instances
+    props = regionprops(image_label)
 
-    if smoothness:
-        image_cleaned = _smooth_instance(image_cleaned, smoothness)
+    # get equivalent diameter and average it
+    diameter = 0
+    n = len(props)
+    for prop in props:
+        diameter += prop.equivalent_diameter
+    if n > 0:
+        mean_diameter = diameter / n
+    else:
+        mean_diameter = np.nan
 
-    if delimit_instance:
-        image_cleaned = _delimit_instance(image_cleaned)
+    return mean_diameter
 
-    return image_cleaned
 
+def compute_mean_convexity_ratio(image_label):
+    """Compute the averaged convexity ratio of the segmented instances.
 
-def _remove_small_area(image, min_size):
-    """Remove segmented areas with a small surface.
+    For each instance, we compute the ratio between its area and the area of
+    its convex hull. Then, we average the diameters.
 
     Parameters
     ----------
-    image : np.ndarray, np.int64 or bool
-        Labelled or masked image with shape (y, x).
-    min_size : int
-        Areas with a smaller surface (in pixels) are removed.
+    image_label : np.ndarray, np.int or np.uint
+        Labelled image with shape (y, x).
 
     Returns
     -------
-    image_cleaned : np.ndarray, np.int64 or bool
-        Cleaned image with shape (y, x).
+    mean_convexity_ratio : float
+        Averaged convexity ratio of the segmented instances.
 
     """
-    # remove small object
-    image_cleaned = remove_small_objects(image, min_size=min_size)
+    # check parameters
+    stack.check_array(image_label,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64])
+
+    # compute properties of the segmented instances
+    props = regionprops(image_label)
+
+    # get convexity ratio and average it
+    n = len(props)
+    convexity_ratio = 0
+    for prop in props:
+        convexity_ratio += prop.area / prop.convex_area
+    if n > 0:
+        mean_convexity_ratio = convexity_ratio / n
+    else:
+        mean_convexity_ratio = np.nan
+
+    return mean_convexity_ratio
 
-    return image_cleaned
 
+def compute_surface_ratio(image_label):
+    """Compute the averaged surface ratio of the segmented instances.
 
-def _fill_hole(image):
-    """Fill holes within the segmented areas.
+    We compute the proportion of surface occupied by instances.
 
     Parameters
     ----------
-    image : np.ndarray, np.int64 or bool
-        Labelled or masked image with shape (y, x).
+    image_label : np.ndarray, np.int or np.uint
+        Labelled image with shape (y, x).
 
     Returns
     -------
-    image_cleaned : np.ndarray, np.int64 or bool
-        Cleaned image with shape (y, x).
+    surface_ratio : float
+        Surface ratio of the segmented instances.
 
     """
-    # fill holes in a binary mask
-    if image.dtype == bool:
-        image_cleaned = ndi.binary_fill_holes(image)
+    # check parameters
+    stack.check_array(image_label,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64])
 
-    # fill holes in a labelled image
-    else:
-        image_cleaned = np.zeros_like(image)
-        for i in range(1, image.max() + 1):
-            image_binary = image == i
-            image_binary = ndi.binary_fill_holes(image_binary)
-            image_cleaned[image_binary] = i
+    # compute surface ratio
+    surface_instances = image_label > 0
+    area_instances = surface_instances.sum()
+    surface_ratio = area_instances / image_label.size
 
-    return image_cleaned
+    return surface_ratio
 
 
-def _smooth_instance(image, radius):
-    """Apply a median filter to smooth instance boundaries.
+def count_instances(image_label):
+    """Count the number of instances annotated in the image.
 
     Parameters
     ----------
-    image : np.ndarray, np.int64 or bool
-        Labelled or masked image with shape (y, x).
-    radius : int
-        Radius of the kernel for the median filter. The higher the smoother.
+    image_label : np.ndarray, np.int or np.uint
+        Labelled image with shape (y, x).
 
     Returns
     -------
-    image_cleaned : np.ndarray, np.int64 or bool
-        Cleaned image with shape (y, x).
+    nb_instances : int
+        Number of instances in the image.
 
     """
-    # smooth instance boundaries for a binary mask
-    if image.dtype == bool:
-        image_cleaned = image.astype(np.uint8)
-        image_cleaned = stack.median_filter(image_cleaned, "disk", radius)
-        image_cleaned = image_cleaned.astype(bool)
+    # check parameters
+    stack.check_array(image_label,
+                      ndim=2,
+                      dtype=[np.uint8, np.uint16, np.int64])
 
-    # smooth instance boundaries for a labelled image
+    indices = set(image_label.ravel())
+    if 0 in indices:
+        nb_instances = len(indices) - 1
     else:
-        if image.max() <= 65535 and image.min() >= 0:
-            image_cleaned = image.astype(np.uint16)
-            image_cleaned = stack.median_filter(image_cleaned, "disk", radius)
-            image_cleaned = image_cleaned.astype(np.int64)
-        else:
-            raise ValueError("Segmentation boundaries can't be smoothed "
-                             "because more than 65535 has been detected in "
-                             "the image. Smoothing is performed with 16-bit "
-                             "unsigned integer images.")
+        nb_instances = len(indices)
 
-    return image_cleaned
+    return nb_instances
 
 
-def _delimit_instance(image):
-    """Subtract an eroded image to a dilated one in order to prevent
-    boundaries contact.
+# ### Format and crop images ###
+
+def resize_image(image, output_shape, method="bilinear"):
+    """Resize an image with bilinear interpolation or nearest neighbor method.
 
     Parameters
     ----------
-    image : np.ndarray, np.int64 or bool
-        Labelled or masked image with shape (y, x).
+    image : np.ndarray
+        Image to resize.
+    output_shape : Tuple[int]
+        Shape of the resized image.
+    method : str
+        Interpolation method to use.
 
     Returns
     -------
-    image_cleaned : np.ndarray, np.int64 or bool
-        Cleaned image with shape (y, x).
+    image_resized : np.ndarray
+        Resized image.
 
     """
-    # handle 64 bit integer
-    original_dtype = image.dtype
-    if image.dtype == np.int64:
-        image = image.astype(np.float64)
-
-    # erode-dilate mask
-    image_dilated = stack.dilation_filter(image, "disk", 1)
-    image_eroded = stack.erosion_filter(image, "disk", 1)
-    if original_dtype == bool:
-        borders = image_dilated & ~image_eroded
-        image_cleaned = image.copy()
-        image_cleaned[borders] = False
+    # check parameters
+    stack.check_parameter(output_shape=tuple, method=str)
+    stack.check_array(image,
+                      ndim=[2, 3],
+                      dtype=[np.uint8, np.uint16, np.float32])
+
+    # resize image
+    if method == "bilinear":
+        image_resized = resize(image, output_shape,
+                               mode="reflect", preserve_range=True,
+                               order=1, anti_aliasing=True)
+    elif method == "nearest":
+        image_resized = resize(image, output_shape,
+                               mode="reflect", preserve_range=True,
+                               order=0, anti_aliasing=False)
     else:
-        borders = image_dilated - image_eroded
-        image_cleaned = image.copy()
-        image_cleaned[borders > 0] = 0
-        image_cleaned = image_cleaned.astype(original_dtype)
+        raise ValueError("Method {0} is not available. Choose between "
+                         "'bilinear' or 'nearest' instead.".format(method))
 
-    return image_cleaned
+    # cast output dtype
+    image_resized = image_resized.astype(image.dtype)
 
+    return image_resized
 
-# ### Instances measures ###
 
-def compute_instances_mean_diameter(image_label):
-    """Compute the averaged size of the segmented instances.
-
-    For each instance, we compute the diameter of an object with an equivalent
-    surface. Then, we average the diameters.
+def get_marge_padding(height, width, x):
+    """Pad image to make its shape a multiple of `x`.
 
     Parameters
     ----------
-    image_label : np.ndarray, np.int64
-        Labelled image with shape (y, x).
+    height : int
+        Original height of the image.
+    width : int
+        Original width of the image.
+    x : int
+        Padded image have a `height` and `width` multiple of `x`.
 
     Returns
     -------
-    mean_diameter : float
-        Averaged size of the segmented instances.
+    marge_padding : List[List]
+        List of lists with the format
+        [[`marge_height_t`, `marge_height_b`], [`marge_width_l`,
+        `marge_width_r`]].
 
     """
     # check parameters
-    stack.check_array(image_label, ndim=2, dtype=np.int64)
-
-    # compute properties of the segmented instances
-    props = regionprops(image_label)
-
-    # get equivalent diameter and average it
-    diameter = []
-    for prop in props:
-        diameter.append(prop.equivalent_diameter)
-    mean_diameter = np.mean(diameter)
+    stack.check_parameter(height=int, width=int, x=int)
 
-    return mean_diameter
+    # pad height and width to make it multiple of x
+    marge_sup_height = x - (height % x)
+    marge_sup_height_l = int(marge_sup_height / 2)
+    marge_sup_height_r = marge_sup_height - marge_sup_height_l
+    marge_sup_width = x - (width % x)
+    marge_sup_width_l = int(marge_sup_width / 2)
+    marge_sup_width_r = marge_sup_width - marge_sup_width_l
+    marge_padding = [[marge_sup_height_l, marge_sup_height_r],
+                     [marge_sup_width_l, marge_sup_width_r]]
 
+    return marge_padding
 
-# ### Nuclei-cells matching
 
-def match_nuc_cell(nuc_label, cell_label):
-    """Match each nucleus instance with the most overlapping cell instance.
+def compute_image_standardization(image):
+    """Normalize image by computing its z score.
 
     Parameters
     ----------
-    nuc_label : np.ndarray, np.int or np.uint
-        Labelled image of nuclei with shape (y, x).
-    cell_label : np.ndarray, np.int or np.uint
-        Labelled image of cells with shape (y, x).
+    image : np.ndarray
+        Image to normalize with shape (y, x).
 
     Returns
     -------
-    new_nuc_label : np.ndarray, np.int or np.uint
-        Labelled image of nuclei with shape (y, x).
-    new_cell_label : np.ndarray, np.int or np.uint
-        Labelled image of cells with shape (y, x).
+    normalized_image : np.ndarray
+        Normalized image with shape (y, x).
 
     """
     # check parameters
-    stack.check_array(nuc_label,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
-    stack.check_array(cell_label,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(image, ndim=2, dtype=[np.uint8, np.uint16, np.float32])
 
-    # initialize new labelled images
-    new_nuc_label = np.zeros_like(nuc_label)
-    new_cell_label = np.zeros_like(cell_label)
-
-    # match nuclei and cells
-    label_max = nuc_label.max()
-    for i_nuc in range(1, label_max + 1):
-        # check if a nucleus is labelled with this value
-        nuc_mask = nuc_label == i_nuc
-        if nuc_mask.sum() == 0:
-            continue
-        # check if a cell is labelled with this value
-        i_cell = _get_most_frequent_value(cell_label[nuc_mask])
-        if i_cell == 0:
-            continue
-        # assign cell and nucleus
-        cell_mask = cell_label == i_cell
-        cell_mask |= nuc_mask
-        new_nuc_label[nuc_mask] = i_nuc
-        new_cell_label[cell_mask] = i_nuc
-        # remove pixel already assigned
-        nuc_label[nuc_mask] = 0
-        cell_label[cell_mask] = 0
-
-    return new_nuc_label, new_cell_label
-
-
-def _get_most_frequent_value(array):
-    """Count the most frequent value in a array.
+    # check image is in 2D
+    if len(image.shape) != 2:
+        raise ValueError("'image' should be a 2-d array. Not {0}-d array"
+                         .format(len(image.shape)))
 
-    Parameters
-    ----------
-    array : np.ndarray, np.uint or np.int
-        Array-like object.
-
-    Returns
-    -------
-    value : int
-        Most frequent integer in the array.
+    # compute mean and standard deviation
+    m = np.mean(image)
+    adjusted_stddev = max(np.std(image), 1.0 / np.sqrt(image.size))
 
-    """
-    value = np.argmax(np.bincount(array))
+    # normalize image
+    normalized_image = (image - m) / adjusted_stddev
 
-    return value
+    return normalized_image
diff --git a/bigfish/stack/__init__.py b/bigfish/stack/__init__.py
index 8bd6dfcd..06e1f5e4 100644
--- a/bigfish/stack/__init__.py
+++ b/bigfish/stack/__init__.py
@@ -21,6 +21,8 @@
 from .utils import check_input_data
 from .utils import moving_average
 from .utils import centered_moving_average
+from .utils import get_sigma
+from .utils import get_radius
 
 from .io import read_image
 from .io import read_dv
@@ -58,8 +60,6 @@
 from .projection import mean_projection
 from .projection import median_projection
 from .projection import focus_projection
-from .projection import focus_projection_fast
-from .projection import focus_measurement
 from .projection import in_focus_selection
 from .projection import get_in_focus_indices
 
@@ -80,6 +80,11 @@
 from .postprocess import from_coord_to_surface
 
 from .augmentation import augment_2d
+from .augmentation import augment_2d_function
+from .augmentation import augment_8_times
+from .augmentation import augment_8_times_reversed
+
+from .quality import compute_focus
 
 
 _utils = [
@@ -96,7 +101,9 @@
     "compute_hash",
     "check_input_data",
     "moving_average",
-    "centered_moving_average"]
+    "centered_moving_average",
+    "get_sigma",
+    "get_radius"]
 
 _io = [
     "read_image",
@@ -138,10 +145,8 @@
     "mean_projection",
     "median_projection",
     "in_focus_selection",
-    "focus_measurement",
     "get_in_focus_indices",
-    "focus_projection",
-    "focus_projection_fast"]
+    "focus_projection"]
 
 _illumination = [
     "compute_illumination_surface",
@@ -162,8 +167,14 @@
     "from_coord_to_surface"]
 
 _augmentation = [
-    "augment_2d"]
+    "augment_2d",
+    "augment_2d_function",
+    "augment_8_times",
+    "augment_8_times_reversed"]
+
+_quality = [
+    "compute_focus"]
 
 
 __all__ = (_utils + _io + _preprocess + _postprocess + _filter + _projection +
-           _illumination + _augmentation)
+           _illumination + _augmentation + _quality)
diff --git a/bigfish/stack/augmentation.py b/bigfish/stack/augmentation.py
index 9c63a737..047e4e7a 100644
--- a/bigfish/stack/augmentation.py
+++ b/bigfish/stack/augmentation.py
@@ -17,7 +17,7 @@ def augment_2d(image):
     Parameters
     ----------
     image : np.ndarray
-        Image to augment with shape (y, x, channels).
+        Image to augment with shape (y, x, channels) or (y, x, channels).
 
     Returns
     -------
@@ -42,6 +42,102 @@ def augment_2d(image):
     return image_augmented
 
 
+def augment_2d_function(identity=False):
+    """Choose a random operation to augment a 2-d image.
+
+    Parameters
+    ----------
+    identity : bool
+        Return identity function instead of a random transformation.
+
+    Returns
+    -------
+    random_operation : callable
+        Function to transform a 2-d image.
+
+    """
+    # no random transformation
+    if identity:
+        return _identity
+
+    # randomly choose an operator
+    operations = [_identity,
+                  _flip_h, _flip_v,
+                  _transpose, _transpose_inverse,
+                  _rotation_90, _rotation_180, _rotation_270]
+    random_operation = np.random.choice(operations)
+
+    return random_operation
+
+
+def augment_8_times(image):
+    """Apply every transformation to a 2-d image.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image to augment with shape (y, x, channels).
+
+    Returns
+    -------
+    images_augmented : List[np.ndarray]
+        List of images augmented with shape (y, x, channels).
+
+    """
+    # check input image
+    check_parameter(image=np.ndarray)
+    check_array(image, ndim=[2, 3])
+
+    # initialization
+    images_augmented = []
+
+    # apply all operators
+    operations = [_identity,
+                  _flip_h, _flip_v,
+                  _transpose, _transpose_inverse,
+                  _rotation_90, _rotation_180, _rotation_270]
+    for operation in operations:
+        augmented_image = operation(image)
+        images_augmented.append(augmented_image)
+
+    return images_augmented
+
+
+def augment_8_times_reversed(images_augmented):
+    """Apply every transformation back to return the original 2-d image.
+
+    Parameters
+    ----------
+    images_augmented : List[np.ndarray]
+        List of images augmented with shape (y, x, channels).
+
+    Returns
+    -------
+    images_original : List[np.ndarray]
+        List of original images with shape (y, x, channels).
+
+    """
+    # check input image
+    check_parameter(images_augmented=list)
+    for image_augmented in images_augmented:
+        check_array(image_augmented, ndim=[2, 3])
+
+    # initialization
+    images_original = []
+
+    # apply all operators
+    operations = [_identity,
+                  _flip_h, _flip_v,
+                  _transpose, _transpose_inverse,
+                  _rotation_270, _rotation_180, _rotation_90]
+    for i, image_augmented in enumerate(images_augmented):
+        operation = operations[i]
+        image_original = operation(image_augmented)
+        images_original.append(image_original)
+
+    return images_original
+
+
 def _identity(image):
     """Do not apply any operation to the image.
 
diff --git a/bigfish/stack/filter.py b/bigfish/stack/filter.py
index 05d2dca1..516fd540 100644
--- a/bigfish/stack/filter.py
+++ b/bigfish/stack/filter.py
@@ -26,6 +26,7 @@
 from skimage.filters import gaussian
 
 from scipy.ndimage import gaussian_laplace
+from scipy.ndimage import convolve
 
 
 # ### Filters ###
@@ -36,13 +37,13 @@ def _define_kernel(shape, size, dtype):
     Parameters
     ----------
     shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square').
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`).
     size : int, Tuple(int) or List(int)
         The size of the kernel:
-            - For the rectangle we expect two values (height, width).
-            - For the square one value (width).
-            - For the disk and the diamond one value (radius).
+            - For the rectangle we expect two values (`height`, `width`).
+            - For the square one value (`width`).
+            - For the disk and the diamond one value (`radius`).
     dtype : type
         Dtype used for the kernel (the same as the image).
 
@@ -70,18 +71,18 @@ def _define_kernel(shape, size, dtype):
 
 
 def mean_filter(image, kernel_shape, kernel_size):
-    """Apply a mean filter to a 2-d image.
+    """Apply a mean filter to a 2-d through convolution filter.
 
     Parameters
     ----------
-    image : np.ndarray, np.uint
+    image : np.ndarray, np.uint or np.float
         Image with shape (y, x).
     kernel_shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square').
-    kernel_size : int or Tuple(int)
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`).
+    kernel_size : int, Tuple(int) or List(int)
         The size of the kernel. For the rectangle we expect two integers
-        (height, width).
+        (`height`, `width`).
 
     Returns
     -------
@@ -92,17 +93,19 @@ def mean_filter(image, kernel_shape, kernel_size):
     # check parameters
     check_array(image,
                 ndim=2,
-                dtype=[np.uint8, np.uint16])
+                dtype=[np.float32, np.float64, np.uint8, np.uint16])
     check_parameter(kernel_shape=str,
                     kernel_size=(int, tuple, list))
 
-    # get kernel
+    # build kernel
     kernel = _define_kernel(shape=kernel_shape,
                             size=kernel_size,
-                            dtype=image.dtype)
+                            dtype=np.float64)
+    n = kernel.sum()
+    kernel /= n
 
-    # apply filter
-    image_filtered = rank.mean(image, kernel)
+    # apply convolution filter
+    image_filtered = convolve(image, kernel)
 
     return image_filtered
 
@@ -115,11 +118,11 @@ def median_filter(image, kernel_shape, kernel_size):
     image : np.ndarray, np.uint
         Image with shape (y, x).
     kernel_shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square').
-    kernel_size : int or Tuple(int)
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`).
+    kernel_size : int, Tuple(int) or List(int)
         The size of the kernel. For the rectangle we expect two integers
-        (height, width).
+        (`height`, `width`).
 
     Returns
     -------
@@ -153,11 +156,11 @@ def maximum_filter(image, kernel_shape, kernel_size):
     image : np.ndarray, np.uint
         Image with shape (y, x).
     kernel_shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square').
-    kernel_size : int or Tuple(int)
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`).
+    kernel_size : int, Tuple(int) or List(int)
         The size of the kernel. For the rectangle we expect two integers
-        (height, width).
+        (`height`, `width`).
 
     Returns
     -------
@@ -191,11 +194,11 @@ def minimum_filter(image, kernel_shape, kernel_size):
     image : np.ndarray, np.uint
         Image with shape (y, x).
     kernel_shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square').
-    kernel_size : int or Tuple(int)
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`).
+    kernel_size : int, Tuple(int) or List(int)
         The size of the kernel. For the rectangle we expect two integers
-        (height, width).
+        (`height`, `width`).
 
     Returns
     -------
@@ -235,7 +238,8 @@ def log_filter(image, sigma):
         Image with shape (z, y, x) or (y, x).
     sigma : float, int, Tuple(float, int) or List(float, int)
         Sigma used for the gaussian filter (one for each dimension). If it's a
-        scalar, the same sigma is applied to every dimensions.
+        scalar, the same sigma is applied to every dimensions. Can be computed
+        with :func:`bigfish.stack.get_sigma`.
 
     Returns
     -------
@@ -290,7 +294,8 @@ def gaussian_filter(image, sigma, allow_negative=False):
         Image with shape (z, y, x) or (y, x).
     sigma : float, int, Tuple(float, int) or List(float, int)
         Sigma used for the gaussian filter (one for each dimension). If it's a
-        scalar, the same sigma is applied to every dimensions.
+        scalar, the same sigma is applied to every dimensions. Can be computed
+        with :func:`bigfish.stack.get_sigma`.
     allow_negative : bool
         Allow negative values after the filtering or clip them to 0. Not
         compatible with unsigned integer images.
@@ -346,11 +351,11 @@ def remove_background_mean(image, kernel_shape="disk", kernel_size=200):
     image : np.ndarray, np.uint
         Image to process with shape (y, x).
     kernel_shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square').
-    kernel_size : int or Tuple(int)
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`).
+    kernel_size : int, Tuple(int) or List(int)
         The size of the kernel. For the rectangle we expect two integers
-        (height, width).
+        (`height`, `width`).
 
     Returns
     -------
@@ -383,7 +388,8 @@ def remove_background_gaussian(image, sigma):
         Image to process with shape (z, y, x) or (y, x).
     sigma : float, int, Tuple(float, int) or List(float, int)
         Sigma used for the gaussian filter (one for each dimension). If it's a
-        scalar, the same sigma is applied to every dimensions.
+        scalar, the same sigma is applied to every dimensions. Can be computed
+        with :func:`bigfish.stack.get_sigma`.
 
     Returns
     -------
@@ -413,13 +419,13 @@ def dilation_filter(image, kernel_shape=None, kernel_size=None):
     image : np.ndarray
         Image with shape (y, x).
     kernel_shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square'). If None, use cross-shaped structuring
-        element (connectivity=1).
-    kernel_size : int or Tuple(int)
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`). If None, use cross-shaped structuring
+        element (``connectivity=1``).
+    kernel_size : int, Tuple(int) or List(int)
         The size of the kernel. For the rectangle we expect two integers
-        (height, width). If None, use cross-shaped structuring element
-        (connectivity=1).
+        (`height`, `width`). If None, use cross-shaped structuring element
+        (``connectivity=1``).
 
     Returns
     -------
@@ -459,13 +465,13 @@ def erosion_filter(image, kernel_shape=None, kernel_size=None):
     image : np.ndarray
         Image with shape (y, x).
     kernel_shape : str
-        Shape of the kernel used to compute the filter ('diamond', 'disk',
-        'rectangle' or 'square'). If None, use cross-shaped structuring
-        element (connectivity=1).
-    kernel_size : int or Tuple(int)
+        Shape of the kernel used to compute the filter (`diamond`, `disk`,
+        `rectangle` or `square`). If None, use cross-shaped structuring
+        element (``connectivity=1``).
+    kernel_size : int, Tuple(int) or List(int)
         The size of the kernel. For the rectangle we expect two integers
-        (height, width). If None, use cross-shaped structuring element
-        (connectivity=1).
+        (`height`, `width`). If None, use cross-shaped structuring element
+        (``connectivity=1``).
 
     Returns
     -------
diff --git a/bigfish/stack/io.py b/bigfish/stack/io.py
index 1afd451a..20d56540 100644
--- a/bigfish/stack/io.py
+++ b/bigfish/stack/io.py
@@ -3,7 +3,7 @@
 # License: BSD 3 clause
 
 """
-Function used to read data from various sources and store them in a
+Functions used to read data from various sources and store them in a
 multidimensional tensor (numpy.ndarray).
 """
 
@@ -24,14 +24,15 @@
 # ### Read ###
 
 def read_image(path, sanity_check=False):
-    """Read an image with .png, .jpg, .jpeg, .tif or .tiff extension.
+    """Read an image with ``png``, ``jpg``, ``jpeg``, ``tif`` or ``tiff``
+    extension.
 
     Parameters
     ----------
     path : str
         Path of the image to read.
     sanity_check : bool
-        Check if the array returned fit with bigfish pipeline.
+        Check if the array returned fits with bigfish pipeline.
 
     Returns
     -------
@@ -60,14 +61,14 @@ def read_image(path, sanity_check=False):
 
 
 def read_dv(path, sanity_check=False):
-    """Read a video file with .dv extension.
+    """Read a video file with ``dv`` extension.
 
     Parameters
     ----------
     path : str
         Path of the file to read.
     sanity_check : bool
-        Check if the array returned fit with bigfish pipeline.
+        Check if the array returned fits with bigfish pipeline.
 
     Returns
     -------
@@ -93,7 +94,7 @@ def read_dv(path, sanity_check=False):
 
 
 def read_array(path):
-    """Read a numpy array with .npy extension.
+    """Read a numpy array with ``npy`` extension.
 
     Parameters
     ----------
@@ -116,16 +117,16 @@ def read_array(path):
 
 
 def read_array_from_csv(path, dtype=None, delimiter=";", encoding="utf-8"):
-    """Read a numpy array saved in a csv file.
+    """Read a numpy array saved in a ``csv`` file.
 
     Parameters
     ----------
     path : str
-        Path of the csv file to read.
+        Path of the ``csv`` file to read.
     dtype : type or None
         Expected dtype to cast the array.
     delimiter : str
-        Delimiter used in the csv file to separate columns.
+        Delimiter used to separate columns.
     encoding : str
         Encoding to use.
 
@@ -152,14 +153,14 @@ def read_array_from_csv(path, dtype=None, delimiter=";", encoding="utf-8"):
 
 
 def read_dataframe_from_csv(path, delimiter=";", encoding="utf-8"):
-    """Read a numpy array or a pandas object saved in a csv file.
+    """Read a numpy array or a pandas object saved in a ``csv`` file.
 
     Parameters
     ----------
     path : str
-        Path of the csv file to read.
+        Path of the ``csv`` file to read.
     delimiter : str
-        Delimiter used in the csv file to separate columns.
+        Delimiter used to separate columns.
     encoding : str
         Encoding to use.
 
@@ -181,7 +182,7 @@ def read_dataframe_from_csv(path, delimiter=";", encoding="utf-8"):
 
 
 def read_uncompressed(path, verbose=False):
-    """Read a NpzFile object with .npz extension.
+    """Read a NpzFile object with ``npz`` extension.
 
     Parameters
     ----------
@@ -209,8 +210,8 @@ def read_uncompressed(path, verbose=False):
 
 
 def read_cell_extracted(path, verbose=False):
-    """Read a NpzFile object with .npz extension, previously written with
-    bigfish.stack.save_cell_extracted.
+    """Read a NpzFile object with ``npz`` extension, previously written with
+    :func:`bigfish.stack.save_cell_extracted`.
 
     Parameters
     ----------
@@ -224,9 +225,12 @@ def read_cell_extracted(path, verbose=False):
     cell_results : Dict
         Dictionary including information about the cell (image, masks,
         coordinates arrays). Minimal information are :
-        - bbox : bounding box coordinates (min_y, min_x, max_y, max_x).
-        - cell_coord : boundary coordinates of the cell.
-        - cell_mask : mask of the cell.
+
+        * `cell_id`: Unique id of the cell.
+        * `bbox`: bounding box coordinates with the order (`min_y`, `min_x`,
+          `max_y`, `max_x`).
+        * `cell_coord`: boundary coordinates of the cell.
+        * `cell_mask`: mask of the cell.
 
     """
     # read compressed file
@@ -250,16 +254,6 @@ def save_image(image, path, extension="tif"):
 
     The dimensions should be in the following order: (round, channel, z, y, x).
 
-    Additional notes:
-    - If the image has more than 2 dimensions, 'tif' and 'tiff' extensions are
-    required ('png' extension does not handle 3-d images other than (M, N, 3)
-    or (M, N, 4) shapes).
-    - A 2-d boolean image can be saved in 'png', 'jpg' or 'jpeg'
-    (cast in np.uint8).
-    - A multidimensional boolean image should be saved with
-    bigfish.stack.save_array function or as a 0-1 images with 'tif'/'tiff'
-    extension.
-
     Parameters
     ----------
     image : np.ndarray
@@ -267,11 +261,19 @@ def save_image(image, path, extension="tif"):
     path : str
         Path of the saved image.
     extension : str
-        Default extension to save the image (among 'png', 'jpg', 'jpeg', 'tif'
-        or 'tiff').
-
-    Returns
-    -------
+        Default extension to save the image (among ``png``, ``jpg``, ``jpeg``,
+        ``tif`` or ``tiff``).
+
+    Notes
+    -----
+    * If the image has more than 2 dimensions, ``tif`` and ``tiff`` extensions
+      are required (``png`` extension does not handle 3-d images other than
+      (M, N, 3) or (M, N, 4) shapes).
+    * A 2-d boolean image can be saved in ``png``, ``jpg`` or ``jpeg`` (cast in
+      np.uint8).
+    * A multidimensional boolean image should be saved with
+      :func:`bigfish.stack.save_array` or as a boolean images with ``tif``/
+      ``tiff`` extension.
 
     """
     # check image and parameters
@@ -339,11 +341,9 @@ def save_image(image, path, extension="tif"):
         warnings.filterwarnings(action="ignore", category=UserWarning)
         io.imsave(path, image)
 
-    return
-
 
 def save_array(array, path):
-    """Save an array in a .npy extension file.
+    """Save an array in a ``npy`` extension file.
 
     The input array should have between 2 and 5 dimensions, with boolean,
     (unsigned) integer, or float.
@@ -355,9 +355,6 @@ def save_array(array, path):
     path : str
         Path of the saved array.
 
-    Returns
-    -------
-
     """
     # check array and path
     check_parameter(path=str)
@@ -375,26 +372,21 @@ def save_array(array, path):
     # save array
     np.save(path, array)
 
-    return
-
 
 def save_data_to_csv(data, path, delimiter=";"):
-    """Save a numpy array or a pandas object into a csv file.
+    """Save a numpy array or a pandas object into a ``csv`` file.
 
-    The input should be a pandas object (Series or DataFrame) or a numpy array
-    with 2 dimensions and (unsigned) integer or float.
+    The input should be a pandas object (`Series` or `DataFrame`) or a
+    numpy array with 2 dimensions and (unsigned) integer or float.
 
     Parameters
     ----------
     data : np.ndarray, pd.Series or pd.DataFrame
         Data to save.
     path : str
-        Path of the saved csv file.
+        Path of the saved ``csv`` file.
     delimiter : str
-        Delimiter used in the csv file to separate columns.
-
-    Returns
-    -------
+        Delimiter used to separate columns.
 
     """
     # check parameters
@@ -433,28 +425,25 @@ def save_data_to_csv(data, path, delimiter=";"):
         data.to_csv(path, sep=delimiter, header=True, index=False,
                     encoding="utf-8")
 
-    return
-
 
 def save_cell_extracted(cell_results, path):
-    """Save cell-level results from bigfish.stack.extract_cell in a NpzFile
-    object with .npz extension.
+    """Save cell-level results from :func:`bigfish.stack.extract_cell` in a
+    NpzFile object with ``npz`` extension.
 
     Parameters
     ----------
     cell_results : Dict
         Dictionary including information about the cell (image, masks,
         coordinates arrays). Minimal information are:
-        - cell_id -> Unique id of the cell.
-        - bbox -> bounding box coordinates (min_y, min_x, max_y, max_x).
-        - cell_coord -> boundary coordinates of the cell.
-        - cell_mask -> mask of the cell.
+
+        * `cell_id`: Unique id of the cell.
+        * `bbox`: bounding box coordinates with the order (`min_y`, `min_x`,
+          `max_y`, `max_x`).
+        * `cell_coord`: boundary coordinates of the cell.
+        * `cell_mask`: mask of the cell.
     path : str
         Path of the saved array.
 
-    Returns
-    -------
-
     """
     # check parameters
     check_parameter(cell_results=dict,
@@ -466,5 +455,3 @@ def save_cell_extracted(cell_results, path):
 
     # save compressed file
     np.savez(path, **cell_results)
-
-    return
diff --git a/bigfish/stack/postprocess.py b/bigfish/stack/postprocess.py
index edf713b9..3b2f490b 100644
--- a/bigfish/stack/postprocess.py
+++ b/bigfish/stack/postprocess.py
@@ -29,7 +29,7 @@ def identify_objects_in_region(mask, coord, ndim):
     ----------
     mask : np.ndarray, bool
         Binary mask of the targeted region with shape (y, x).
-    coord : np.ndarray, np.int64
+    coord : np.ndarray
         Array with two dimensions. One object per row, zyx or yx coordinates
         in the first 3 or 2 columns.
     ndim : int
@@ -37,9 +37,9 @@ def identify_objects_in_region(mask, coord, ndim):
 
     Returns
     -------
-    coord_in : np.ndarray, np.int64
+    coord_in : np.ndarray
         Coordinates of the objects detected inside the region.
-    coord_out : np.ndarray, np.int64
+    coord_out : np.ndarray
         Coordinates of the objects detected outside the region.
 
     """
@@ -51,7 +51,7 @@ def identify_objects_in_region(mask, coord, ndim):
                        bool])
     check_array(coord,
                 ndim=2,
-                dtype=np.int64)
+                dtype=[np.int64, np.float64])
 
     # check number of dimensions
     if ndim not in [2, 3]:
@@ -67,15 +67,21 @@ def identify_objects_in_region(mask, coord, ndim):
     if mask.dtype != bool:
         mask = mask.astype(bool)
 
+    # cast coordinates dtype if necessary
+    if coord.dtype == np.int64:
+        coord_int = coord
+    else:
+        coord_int = np.round(coord).astype(np.int64)
+
     # remove objects inside the region
-    mask_in = mask[coord[:, ndim - 2], coord[:, ndim - 1]]
+    mask_in = mask[coord_int[:, ndim - 2], coord_int[:, ndim - 1]]
     coord_in = coord[mask_in]
     coord_out = coord[~mask_in]
 
     return coord_in, coord_out
 
 
-def remove_transcription_site(rna, foci, nuc_mask, ndim):
+def remove_transcription_site(rna, clusters, nuc_mask, ndim):
     """Distinguish RNA molecules detected in a transcription site from the
     rest.
 
@@ -83,15 +89,15 @@ def remove_transcription_site(rna, foci, nuc_mask, ndim):
 
     Parameters
     ----------
-    rna : np.ndarray, np.int64
+    rna : np.ndarray
         Coordinates of the detected RNAs with shape (nb_spots, 4) or
         (nb_spots, 3). One coordinate per dimension (zyx or yx coordinates)
-        plus the index of the foci assigned to the RNA. If no foci was
+        plus the index of the cluster assigned to the RNA. If no cluster was
         assigned, value is -1.
-    foci : np.ndarray, np.int64
-        Array with shape (nb_foci, 5) or (nb_foci, 4). One coordinate per
-        dimension for the foci centroid (zyx or yx coordinates),
-        the number of RNAs detected in the foci and its index.
+    clusters : np.ndarray
+        Array with shape (nb_clusters, 5) or (nb_clusters, 4). One coordinate
+        per dimension for the clusters centroid (zyx or yx coordinates),
+        the number of RNAs detected in the clusters and their index.
     nuc_mask : np.ndarray, bool
         Binary mask of the nuclei region with shape (y, x).
     ndim : int
@@ -99,16 +105,16 @@ def remove_transcription_site(rna, foci, nuc_mask, ndim):
 
     Returns
     -------
-    rna_out_ts : np.ndarray, np.int64
+    rna_out_ts : np.ndarray
         Coordinates of the detected RNAs with shape (nb_spots, 4) or
         (nb_spots, 3). One coordinate per dimension (zyx or yx coordinates)
         plus the index of the foci assigned to the RNA. If no foci was
         assigned, value is -1. RNAs from transcription sites are removed.
-    foci : np.ndarray, np.int64
+    foci : np.ndarray
         Array with shape (nb_foci, 5) or (nb_foci, 4). One coordinate per
         dimension for the foci centroid (zyx or yx coordinates),
         the number of RNAs detected in the foci and its index.
-    ts : np.ndarray, np.int64
+    ts : np.ndarray
         Array with shape (nb_ts, 5) or (nb_ts, 4). One coordinate per
         dimension for the transcription site centroid (zyx or yx coordinates),
         the number of RNAs detected in the transcription site and its index.
@@ -117,11 +123,11 @@ def remove_transcription_site(rna, foci, nuc_mask, ndim):
     # check parameters
     check_array(rna,
                 ndim=2,
-                dtype=np.int64)
+                dtype=[np.int64, np.float64])
 
     # discriminate foci from transcription sites
     ts, foci = identify_objects_in_region(
-        nuc_mask, foci, ndim)
+        nuc_mask, clusters, ndim)
 
     # filter out rna from transcription sites
     rna_in_ts = ts[:, ndim + 1]
@@ -136,10 +142,10 @@ def remove_transcription_site(rna, foci, nuc_mask, ndim):
 def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
                  others_coord=None, image=None, others_image=None,
                  remove_cropped_cell=True, check_nuc_in_cell=True):
-    """Extract cell-level results of a FoV.
+    """Extract cell-level results for an image.
 
     The function gathers different segmentation and detection results obtained
-    at the FoV level and assigns each of them to the individual cells.
+    at the image level and assigns each of them to the individual cells.
 
     Parameters
     ----------
@@ -150,7 +156,7 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
     nuc_label : np.ndarray, np.uint or np.int
         Image with labelled nuclei and shape (y, x). If None, individual
         nuclei are not assigned to each cell.
-    rna_coord : np.ndarray, np.int64
+    rna_coord : np.ndarray
         Coordinates of the detected RNAs with zyx or yx coordinates in the
         first 3 or 2 columns. If None, RNAs are not assigned to individual
         cells.
@@ -163,8 +169,7 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
         the spots. If None, no others elements are assigned to the individual
         cells.
     image : np.ndarray, np.uint
-        Image in 2-d of the FoV. If None, image of the individual cells are not
-        extracted.
+        Image in 2-d. If None, image of the individual cells are not extracted.
     others_image : Dict[np.ndarray]
         Dictionary of images to crop. If None, no others image of the
         individual cells are extracted.
@@ -176,13 +181,15 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
     Returns
     -------
     fov_results : List[Dict]
-        List of dictionaries, one per cell segmented in the FoV. Each
+        List of dictionaries, one per cell segmented in the image. Each
         dictionary includes information about the cell (image, masks,
         coordinates arrays). Minimal information are:
-        - cell_id -> Unique id of the cell.
-        - bbox -> bounding box coordinates (min_y, min_x, max_y, max_x).
-        - cell_coord -> boundary coordinates of the cell.
-        - cell_mask -> mask of the cell.
+
+        * `cell_id`: Unique id of the cell.
+        * `bbox`: bounding box coordinates with the order (`min_y`, `min_x`,
+          `max_y`, `max_x`).
+        * `cell_coord`: boundary coordinates of the cell.
+        * `cell_mask`: mask of the cell.
 
     """
     # check parameters
@@ -195,7 +202,7 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
     if nuc_label is not None:
         check_array(nuc_label, ndim=2, dtype=[np.uint8, np.uint16, np.int64])
     if rna_coord is not None:
-        check_array(rna_coord, ndim=2, dtype=np.int64)
+        check_array(rna_coord, ndim=2, dtype=[np.int64, np.float64])
     if image is not None:
         check_array(image, ndim=2, dtype=[np.uint8, np.uint16])
     actual_keys = ["cell_id", "bbox", "cell_coord", "cell_mask", "nuc_coord",
@@ -208,7 +215,7 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
             else:
                 actual_keys.append(key)
             array = others_coord[key]
-            check_array(array, ndim=2, dtype=np.int64)
+            check_array(array, ndim=2, dtype=[np.int64, np.float64])
             if array.shape[1] < ndim:
                 warnings.warn("Array in 'others_coord' have less coordinates "
                               "({0}) than the minimum number of spatial "
@@ -392,7 +399,7 @@ def extract_spots_from_frame(spots, z_lim=None, y_lim=None, x_lim=None):
 
     Parameters
     ----------
-    spots : np.ndarray, np.int64
+    spots : np.ndarray
         Coordinate of the spots detected inside foci, with shape (nb_spots, 3)
         or (nb_spots, 4). One coordinate per dimension (zyx coordinates) plus
         the index of the foci if necessary.
@@ -405,7 +412,7 @@ def extract_spots_from_frame(spots, z_lim=None, y_lim=None, x_lim=None):
 
     Returns
     -------
-    extracted_spots : np.ndarray, np.int64
+    extracted_spots : np.ndarray
         Coordinate of the spots detected inside foci, with shape (nb_spots, 3)
         or (nb_spots, 4). One coordinate per dimension (zyx coordinates) plus
         the index of the foci if necessary.
@@ -414,8 +421,7 @@ def extract_spots_from_frame(spots, z_lim=None, y_lim=None, x_lim=None):
     # check parameters
     check_array(spots,
                 ndim=2,
-                dtype=[np.int64],
-                allow_nan=False)
+                dtype=[np.int64, np.float64])
     check_parameter(z_lim=(tuple, type(None)),
                     y_lim=(tuple, type(None)),
                     x_lim=(tuple, type(None)))
@@ -439,19 +445,20 @@ def extract_spots_from_frame(spots, z_lim=None, y_lim=None, x_lim=None):
 
 
 def summarize_extraction_results(fov_results, ndim, path_output=None):
-    """Summarize results extracted from a field of view and store them in a
-    dataframe.
+    """Summarize results extracted from an image and store them in a dataframe.
 
     Parameters
     ----------
     fov_results : List[Dict]
-        List of dictionaries, one per cell segmented in the FoV. Each
+        List of dictionaries, one per cell segmented in the image. Each
         dictionary includes information about the cell (image, masks,
         coordinates arrays). Minimal information are:
-        - cell_id: Unique id of the cell.
-        - bbox: bounding box coordinates (min_y, min_x, max_y, max_x).
-        - cell_coord: boundary coordinates of the cell.
-        - cell_mask: mask of the cell.
+
+        * `cell_id`: Unique id of the cell.
+        * `bbox`: bounding box coordinates with the order (`min_y`, `min_x`,
+          `max_y`, `max_x`).
+        * `cell_coord`: boundary coordinates of the cell.
+        * `cell_mask`: mask of the cell.
     ndim : int
         Number of spatial dimensions to consider (2 or 3).
     path_output : str or None
@@ -461,12 +468,13 @@ def summarize_extraction_results(fov_results, ndim, path_output=None):
     -------
     df : pd.DataFrame
         Dataframe with summarized results from the field of view, at the cell
-        level. A minimum number of indicators are returned:
-        - cell_id -> Unique id of the cell.
-        Other indicators are summarized if available:
-        - nb_rna -> Number of detected rna in the cell.
-        - nb_rna_in_nuc -> Number of detected rna inside the nucleus.
-        - nb_rna_out_nuc -> Number of detected rna outside the nucleus.
+        level. At least `cell_id` (Unique id of the cell) is returned. Other
+        indicators are summarized if available:
+
+        * `nb_rna`: Number of detected rna in the cell.
+        * `nb_rna_in_nuc`: Number of detected rna inside the nucleus.
+        * `nb_rna_out_nuc`: Number of detected rna outside the nucleus.
+
         Extra coordinates elements detected are counted in the cell and
         summarized as well.
 
@@ -493,7 +501,7 @@ def summarize_extraction_results(fov_results, ndim, path_output=None):
             continue
         others_coord = cell_results[key]
         if (not isinstance(others_coord, np.ndarray)
-                or others_coord.dtype != np.int64):
+                or others_coord.dtype not in [np.int64, np.float64]):
             continue
         _extra_coord[key] = []
 
@@ -567,9 +575,9 @@ def center_mask_coord(main, others=None):
     """Center a 2-d binary mask (surface or boundaries) or a 2-d localization
     coordinates array and pad it.
 
-    One mask or coordinates array should be at least provided ('main'). If
-    others masks or arrays are provided ('others'), they will be transformed
-    like 'main'. All the provided masks should have the same shape.
+    One mask or coordinates array should be at least provided (`main`). If
+    others masks or arrays are provided (`others`), they will be transformed
+    like `main`. All the provided masks should have the same shape.
 
     Parameters
     ----------
@@ -586,8 +594,8 @@ def center_mask_coord(main, others=None):
         Centered binary image with shape (y, x).
     others_centered : List(np.ndarray)
         List of centered binary image with shape (y, x), centered array of
-        coordinates with shape (nb_points, 2) or centered array of
-        coordinates with shape (nb_points, 3).
+        coordinates with shape (nb_points, 2) or centered array of coordinates
+        with shape (nb_points, 3).
 
     """
     # TODO allow the case when coordinates do not represent external boundaries
@@ -849,7 +857,7 @@ def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
     keeping only the inside pixels of the object surface.
 
     If others coordinates are provided (nucleus and mRNAs), the relative
-    binary matrix is build with the same shape as the main coordinates (cell).
+    binary matrix is built with the same shape as the main coordinates (cell).
 
     Parameters
     ----------
@@ -872,8 +880,7 @@ def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
     rna_binary : np.ndarray, bool
         Binary image of mRNAs localizations with shape (y, x).
     new_rna_coord : np.ndarray, np.int64
-        Array of mRNAs coordinates with shape (nb_points, 2) or
-        (nb_points, 3).
+        Array of mRNAs coordinates with shape (nb_points, 2) or (nb_points, 3).
 
     """
     # check parameters
diff --git a/bigfish/stack/preprocess.py b/bigfish/stack/preprocess.py
index 77950fd1..864cc1cc 100644
--- a/bigfish/stack/preprocess.py
+++ b/bigfish/stack/preprocess.py
@@ -690,7 +690,7 @@ def rescale(tensor, channel_to_stretch=None, stretching_percentile=99.9):
     between 0 and 1 for float tensor). We can improve the contrast by
     stretching a smaller range of pixel intensity: between the minimum value
     of a channel and percentile value of the channel (cf.
-    'stretching_percentile').
+    ``stretching_percentile``).
 
     To be consistent with skimage, 64-bit (unsigned) integer images are not
     supported.
@@ -815,16 +815,17 @@ def _rescale_5d(tensor, channel_to_stretch, stretching_percentile):
 def cast_img_uint8(tensor, catch_warning=False):
     """Cast the image in np.uint8 and scale values between 0 and 255.
 
-    Negative values are not allowed as the skimage method 'img_as_ubyte' would
-    clip them to 0. Positives values are scaled between 0 and 255, excepted
-    if they fit directly in 8 bit (in this case values are not modified).
+    Negative values are not allowed as the skimage method ``img_as_ubyte``
+    would clip them to 0. Positives values are scaled between 0 and 255,
+    excepted if they fit directly in 8 bit (in this case values are not
+    modified).
 
     Parameters
     ----------
     tensor : np.ndarray
         Image to cast.
     catch_warning : bool
-        Catch and ignore UserWarning about possible precision or sign loss.
+        Catch and ignore `UserWarning` about possible precision or sign loss.
 
     Returns
     -------
@@ -868,7 +869,7 @@ def cast_img_uint8(tensor, catch_warning=False):
 def cast_img_uint16(tensor, catch_warning=False):
     """Cast the data in np.uint16.
 
-    Negative values are not allowed as the skimage method 'img_as_uint' would
+    Negative values are not allowed as the skimage method ``img_as_uint`` would
     clip them to 0. Positives values are scaled between 0 and 65535, excepted
     if they fit directly in 16 bit (in this case values are not modified).
 
@@ -877,7 +878,7 @@ def cast_img_uint16(tensor, catch_warning=False):
     tensor : np.ndarray
         Image to cast.
     catch_warning : bool
-        Catch and ignore UserWarning about possible precision or sign loss.
+        Catch and ignore `UserWarning` about possible precision or sign loss.
     Returns
     -------
     tensor : np.ndarray, np.uint16
@@ -927,7 +928,7 @@ def cast_img_float32(tensor, catch_warning=False):
     tensor : np.ndarray
         Image to cast.
     catch_warning : bool
-        Catch and ignore UserWarning about possible precision or sign loss.
+        Catch and ignore `UserWarning` about possible precision or sign loss.
 
     Returns
     -------
diff --git a/bigfish/stack/projection.py b/bigfish/stack/projection.py
index ec62a1f6..18b787e1 100644
--- a/bigfish/stack/projection.py
+++ b/bigfish/stack/projection.py
@@ -8,179 +8,123 @@
 
 from .utils import check_array
 from .utils import check_parameter
-
-from .preprocess import cast_img_uint8
-from .preprocess import cast_img_uint16
-
-from .filter import mean_filter
+from .quality import compute_focus
 
 
 # ### Projections 2-d ###
 
-def maximum_projection(tensor):
-    """Project the z-dimension of a tensor, keeping the maximum intensity of
+def maximum_projection(image):
+    """Project the z-dimension of an image, keeping the maximum intensity of
     each yx pixel.
 
     Parameters
     ----------
-    tensor : np.ndarray, np.uint
-        A 3-d tensor with shape (z, y, x).
+    image : np.ndarray, np.uint
+        A 3-d image with shape (z, y, x).
 
     Returns
     -------
-    projected_tensor : np.ndarray, np.uint
-        A 2-d tensor with shape (y, x).
+    projected_image : np.ndarray, np.uint
+        A 2-d image with shape (y, x).
 
     """
     # check parameters
-    check_array(tensor, ndim=3, dtype=[np.uint8, np.uint16])
+    check_array(image, ndim=3, dtype=[np.uint8, np.uint16])
 
-    # project tensor along the z axis
-    projected_tensor = tensor.max(axis=0)
+    # project image along the z axis
+    projected_image = image.max(axis=0)
 
-    return projected_tensor
+    return projected_image
 
 
-def mean_projection(tensor, return_float=False):
-    """Project the z-dimension of a tensor, computing the mean intensity of
+def mean_projection(image, return_float=False):
+    """Project the z-dimension of a image, computing the mean intensity of
     each yx pixel.
 
     Parameters
     ----------
-    tensor : np.ndarray, np.uint
+    image : np.ndarray, np.uint
         A 3-d tensor with shape (z, y, x).
     return_float : bool
         Return a (potentially more accurate) float array.
 
     Returns
     -------
-    projected_tensor : np.ndarray
-        A 2-d tensor with shape (y, x).
+    projected_image : np.ndarray
+        A 2-d image with shape (y, x).
 
     """
     # check parameters
-    check_array(tensor, ndim=3, dtype=[np.uint8, np.uint16])
+    check_array(image, ndim=3, dtype=[np.uint8, np.uint16])
 
-    # project tensor along the z axis
+    # project image along the z axis
     if return_float:
-        projected_tensor = tensor.mean(axis=0)
+        projected_image = image.mean(axis=0)
     else:
-        projected_tensor = tensor.mean(axis=0).astype(tensor.dtype)
+        projected_image = image.mean(axis=0).astype(image.dtype)
 
-    return projected_tensor
+    return projected_image
 
 
-def median_projection(tensor):
-    """Project the z-dimension of a tensor, computing the median intensity of
+def median_projection(image):
+    """Project the z-dimension of a image, computing the median intensity of
     each yx pixel.
 
     Parameters
     ----------
-    tensor : np.ndarray, np.uint
-        A 3-d tensor with shape (z, y, x).
-
-    Returns
-    -------
-    projected_tensor : np.ndarray, np.uint
-        A 2-d tensor with shape (y, x).
-
-    """
-    # check parameters
-    check_array(tensor, ndim=3, dtype=[np.uint8, np.uint16])
-
-    # project tensor along the z axis
-    projected_tensor = np.median(tensor, axis=0)
-    projected_tensor = projected_tensor.astype(tensor.dtype)
-
-    return projected_tensor
-
-
-def focus_projection(tensor):
-    """Project the z-dimension of a tensor as describe in Samacoits Aubin's
-    thesis (part 5.3, strategy 5).
-
-    1) We keep 75% best in-focus z-slices.
-    2) Compute a focus value for each voxel zyx with a 7x7 neighborhood window.
-    3) Keep the median pixel intensity among the top 5 best focus z-slices.
-
-    Parameters
-    ----------
-    tensor : np.ndarray, np.uint
-        A 3-d tensor with shape (z, y, x).
+    image : np.ndarray, np.uint
+        A 3-d image with shape (z, y, x).
 
     Returns
     -------
-    projected_tensor : np.ndarray, np.uint
-        A 2-d tensor with shape (y, x).
+    projected_image : np.ndarray, np.uint
+        A 2-d image with shape (y, x).
 
     """
     # check parameters
-    check_array(tensor, ndim=3, dtype=[np.uint8, np.uint16])
-
-    # remove out-of-focus z-slices
-    in_focus_image = in_focus_selection(tensor,
-                                        proportion=0.75,
-                                        neighborhood_size=30)
-
-    # compute focus value for each voxel with a smaller window.
-    local_focus, _ = focus_measurement(in_focus_image, neighborhood_size=7)
+    check_array(image, ndim=3, dtype=[np.uint8, np.uint16])
 
-    # for each yx pixel, get the indices of the 5 best focus values
-    top_local_focus_indices = np.argsort(local_focus, axis=0)
-    top_local_focus_indices = top_local_focus_indices[-5:, :, :]
-
-    # build a binary matrix with the same shape of our in-focus image to keep
-    # the top focus pixels only
-    mask = [mask_ for mask_ in map(
-        lambda indices: _one_hot_3d(indices, depth=in_focus_image.shape[0]),
-        top_local_focus_indices)]
-    mask = np.sum(mask, axis=0, dtype=in_focus_image.dtype)
-
-    # filter top focus pixels in our in-focus image
-    in_focus_image = np.multiply(in_focus_image, mask)
-
-    # project tensor
-    in_focus_image = in_focus_image.astype(np.float32)
-    in_focus_image[in_focus_image == 0] = np.nan
-    projected_tensor = np.nanmedian(in_focus_image, axis=0)
-    projected_tensor = projected_tensor.astype(tensor.dtype)
+    # project image along the z axis
+    projected_image = np.median(image, axis=0)
+    projected_image = projected_image.astype(image.dtype)
 
-    return projected_tensor
+    return projected_image
 
 
-def focus_projection_fast(tensor, proportion=0.75, neighborhood_size=7,
-                          method="median"):
-    """Project the z-dimension of a tensor.
+def focus_projection(image, proportion=5, neighborhood_size=7,
+                     method="median"):
+    """Project the z-dimension of an image.
 
     Inspired from Samacoits Aubin's thesis (part 5.3, strategy 5). Compare to
     the original algorithm we use the same focus measures to select the
-    in-focus z-slices and project our tensor.
+    in-focus z-slices and project our image.
 
-    1) Compute a focus value for each voxel zyx with a fixed neighborhood size.
-    2) We keep 75% best in-focus z-slices (based on a global focus score).
-    3) Keep the median/maximum pixel intensity among the top 5 best
-    focus z-slices.
+    #. Compute a focus score for each pixel yx with a fixed neighborhood size.
+    #. We keep 75% of z-slices with the highest average focus score.
+    #. Keep the median/maximum pixel intensity among the top 5 z-slices with
+       the highest focus score.
 
     Parameters
     ----------
-    tensor : np.ndarray, np.uint
-        A 3-d tensor with shape (z, y, x).
+    image : np.ndarray, np.uint
+        A 3-d image with shape (z, y, x).
     proportion : float or int
         Proportion of z-slices to keep (float between 0 and 1) or number of
         z-slices to keep (positive integer).
     neighborhood_size : int
         The size of the square used to define the neighborhood of each pixel.
     method : str
-        Projection method applied on the selected pixel values.
+        Projection method applied on the selected pixel values (`median` or
+        `max`).
 
     Returns
     -------
-    projected_tensor : np.ndarray, np.uint
-        A 2-d tensor with shape (y, x).
+    projected_image : np.ndarray, np.uint
+        A 2-d image with shape (y, x).
 
     """
     # check parameters
-    check_array(tensor, ndim=3, dtype=[np.uint8, np.uint16])
+    check_array(image, ndim=3, dtype=[np.uint8, np.uint16])
     check_parameter(proportion=(float, int),
                     neighborhood_size=int)
     if isinstance(proportion, float) and 0 <= proportion <= 1:
@@ -191,247 +135,132 @@ def focus_projection_fast(tensor, proportion=0.75, neighborhood_size=7,
         raise ValueError("'proportion' should be a float between 0 and 1 or a "
                          "positive integer, but not {0}.".format(proportion))
 
-    # compute focus value for each voxel.
-    local_focus, global_focus = focus_measurement(tensor, neighborhood_size)
+    # compute focus measure for each pixel
+    focus = compute_focus(image, neighborhood_size)
 
     # select and keep best z-slices
-    indices_to_keep = get_in_focus_indices(global_focus, proportion)
-    in_focus_image = tensor[indices_to_keep]
-    local_focus = local_focus[indices_to_keep]
+    indices_to_keep = get_in_focus_indices(focus, proportion)
+    in_focus_image = image[indices_to_keep]
+    focus = focus[indices_to_keep]
 
     # for each yx pixel, get the indices of the 5 best focus values
-    top_local_focus_indices = np.argsort(local_focus, axis=0)
-    n = min(local_focus.shape[0], 5)
-    top_local_focus_indices = top_local_focus_indices[-n:, :, :]
+    top_focus_indices = np.argsort(focus, axis=0)
+    n = min(focus.shape[0], 5)
+    top_focus_indices = top_focus_indices[-n:, :, :]
 
     # build a binary matrix with the same shape of our in-focus image to keep
     # the top focus pixels only
     mask = [mask_ for mask_ in map(
         lambda indices: _one_hot_3d(indices, depth=in_focus_image.shape[0]),
-        top_local_focus_indices)]
+        top_focus_indices)]
     mask = np.sum(mask, axis=0, dtype=in_focus_image.dtype)
 
     # filter top focus pixels in our in-focus image
     in_focus_image = np.multiply(in_focus_image, mask)
 
-    # project tensor
-    in_focus_image = in_focus_image.astype(np.float32)
+    # project image
+    in_focus_image = in_focus_image.astype(np.float64)
     in_focus_image[in_focus_image == 0] = np.nan
     if method == "median":
-        projected_tensor = np.nanmedian(in_focus_image, axis=0)
+        projected_image = np.nanmedian(in_focus_image, axis=0)
     elif method == "max":
-        projected_tensor = np.nanmax(in_focus_image, axis=0)
+        projected_image = np.nanmax(in_focus_image, axis=0)
     else:
         raise ValueError("Parameter 'method' should be 'median' or 'max', not "
                          "'{0}'.".format(method))
-    projected_tensor = projected_tensor.astype(tensor.dtype)
+    projected_image = projected_image.astype(image.dtype)
 
-    return projected_tensor
+    return projected_image
 
 
-# ### Focus selection ###
-
-def in_focus_selection(image, proportion, neighborhood_size=30):
-    """Select and keep the slices with the highest level of focus.
-
-    Helmli and Scherer’s mean method used as a focus metric.
+def _one_hot_3d(indices, depth, return_boolean=False):
+    """Build a 3-d one-hot matrix from a 2-d indices matrix.
 
     Parameters
     ----------
-    image : np.ndarray
-        A 3-d tensor with shape (z, y, x).
-    proportion : float or int
-        Proportion of z-slices to keep (float between 0 and 1) or number of
-        z-slices to keep (positive integer).
-    neighborhood_size : int
-        The size of the square used to define the neighborhood of each pixel.
+    indices : np.ndarray, int
+        A 2-d tensor with integer indices and shape (y, x).
+    depth : int
+        Depth of the 3-d one-hot matrix.
+    return_boolean : bool
+        Return a boolean one-hot encoded matrix.
 
     Returns
     -------
-    in_focus_image : np.ndarray
-        A 3-d tensor with shape (z_in_focus, y, x), with out-of-focus z-slice
-        removed.
+    one_hot : np.ndarray
+        A 3-d binary tensor with shape (depth, y, x)
 
     """
     # check parameters
-    check_array(image,
-                ndim=3,
-                dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    check_parameter(proportion=(float, int),
-                    neighborhood_size=int)
-    if isinstance(proportion, float) and 0 <= proportion <= 1:
-        pass
-    elif isinstance(proportion, int) and 0 <= proportion:
-        pass
-    else:
-        raise ValueError("'proportion' should be a float between 0 and 1 or a "
-                         "positive integer, but not {0}.".format(proportion))
+    check_parameter(depth=int)
+    check_array(indices,
+                ndim=2,
+                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+                       np.int8, np.int16, np.int32, np.int64])
 
-    # measure focus level
-    _, global_focus = focus_measurement(image, neighborhood_size)
+    # initialize the 3-d one-hot matrix
+    one_hot = np.zeros((indices.size, depth), dtype=indices.dtype)
 
-    # select and keep best z-slices
-    indices_to_keep = get_in_focus_indices(global_focus, proportion)
-    in_focus_image = image[indices_to_keep]
+    # flatten the matrix to easily one-hot encode it, then reshape it
+    one_hot[np.arange(indices.size), indices.ravel()] = 1
+    one_hot.shape = indices.shape + (depth,)
 
-    return in_focus_image
+    # rearrange the axis
+    one_hot = np.moveaxis(one_hot, source=2, destination=0)
 
+    if return_boolean:
+        one_hot = one_hot.astype(bool)
+
+    return one_hot
 
-def focus_measurement(image, neighborhood_size=30, cast_8bit=False):
-    """Helmli and Scherer’s mean method used as a focus metric.
 
-    For each pixel xy in an image, we compute the ratio:
+# ### Slice selection ###
 
-        R(x, y) = mu(x, y) / I(x, y), if mu(x, y) >= I(x, y)
-    or
-        R(x, y) = I(x, y) / mu(x, y), otherwise
+def in_focus_selection(image, focus, proportion):
+    """Select and keep the 2-d slices with the highest level of focus.
 
-    with I(x, y) the intensity of the pixel xy and mu(x, y) the mean intensity
-    of the pixels of its neighborhood.
+    Helmli and Scherer’s mean method is used as a focus metric.
 
     Parameters
     ----------
     image : np.ndarray
-        A 2-d or 3-d tensor with shape (y, x) or (z, y, x).
-    neighborhood_size : int
-        The size of the square used to define the neighborhood of each pixel.
-    cast_8bit : bool
-        Cast image in 8 bit before measuring the focus scores. Can speed up
-        the computation, but vanish the signal as well.
+        A 3-d tensor with shape (z, y, x).
+    focus : np.ndarray, np.float64
+        A 3-d tensor with a focus metric computed for each pixel of the
+        original image. See :func:`bigfish.stack.compute_focus`.
+    proportion : float or int
+        Proportion of z-slices to keep (float between 0 and 1) or number of
+        z-slices to keep (positive integer).
 
     Returns
     -------
-    ratio : np.ndarray, np.float32
-        A 2-d or 3-d tensor with the R(x, y) computed for each pixel of the
-        original image.
-    global_focus : np.ndarray, np.float32
-        Mean value of the ratio computed for every pixels of each 2-d slice.
-        Can be used as a metric to quantify the focus level this slice. Shape
-        is (z,) for a 3-d image or (,) for a 2-d image.
+    in_focus_image : np.ndarray
+        A 3-d tensor with shape (z_in_focus, y, x), with out-of-focus z-slice
+        removed.
 
     """
     # check parameters
     check_array(image,
-                ndim=[2, 3],
-                dtype=[np.uint8, np.uint16, np.float32, np.float64],
-                allow_nan=False)
-    check_parameter(neighborhood_size=int)
-
-    # cast image in np.uint
-    if image.dtype == np.uint8:
-        pass
-    elif cast_8bit:
-        image = cast_img_uint8(image, catch_warning=True)
-    else:
-        image = cast_img_uint16(image)
-
-    if image.ndim == 2:
-        ratio, global_focus = _focus_measurement_2d(image, neighborhood_size)
-    else:
-        ratio, global_focus = _focus_measurement_3d(image, neighborhood_size)
-
-    return ratio, global_focus
-
-
-def _focus_measurement_2d(image, neighborhood_size):
-    """Helmli and Scherer’s mean method used as a focus metric.
-
-    For each pixel xy in an image, we compute the ratio:
-
-        R(x, y) = mu(x, y) / I(x, y), if mu(x, y) >= I(x, y)
-    or
-        R(x, y) = I(x, y) / mu(x, y), otherwise
-
-    with I(x, y) the intensity of the pixel xy and mu(x, y) the mean intensity
-    of the pixels of its neighborhood.
-
-    Parameters
-    ----------
-    image : np.ndarray, np.uint
-        A 2-d tensor with shape (y, x).
-    neighborhood_size : int
-        The size of the square used to define the neighborhood of each pixel.
-
-    Returns
-    -------
-    ratio : np.ndarray, np.float32
-        A 2-d tensor with the R(x, y) computed for each pixel of the
-        original image.
-    global_focus : np.ndarray, np.float32
-        Mean value of the ratio computed for every pixels of each 2-d slice.
-        Can be used as a metric to quantify the focus level this slice. Shape
-        is () for a 2-d image.
-
-    """
-    # filter the image with a mean filter
-    image_filtered_mean = mean_filter(image, "square", neighborhood_size)
-
-    # case where mu(x, y) >= I(x, y)
-    mask_1 = (image != 0)
-    out_1 = np.zeros_like(image_filtered_mean, dtype=np.float32)
-    ratio_1 = np.divide(image_filtered_mean, image, out=out_1, where=mask_1)
-    ratio_1 = np.where(image_filtered_mean >= image, ratio_1, 0)
-
-    # case where I(x, y) > mu(x, y)
-    mask_2 = image_filtered_mean != 0
-    out_2 = np.zeros_like(image, dtype=np.float32)
-    ratio_2 = np.divide(image, image_filtered_mean, out=out_2, where=mask_2)
-    ratio_2 = np.where(image > image_filtered_mean, ratio_2, 0)
-
-    # compute ratio and global focus for the entire image
-    ratio = ratio_1 + ratio_2
-    ratio = ratio.astype(np.float32)
-    global_focus = ratio.mean()
-
-    return ratio, global_focus
-
-
-def _focus_measurement_3d(image, neighborhood_size):
-    """Helmli and Scherer’s mean method used as a focus metric.
-
-    Parameters
-    ----------
-    image : np.ndarray, np.uint
-        A 3-d tensor with shape (z, y, x).
-    neighborhood_size : int
-        The size of the square used to define the neighborhood of each pixel.
-
-    Returns
-    -------
-    ratio : np.ndarray, np.float32
-        A 3-d tensor with the R(x, y) computed for each pixel of the
-        original image.
-    global_focus : np.ndarray, np.float32
-        Mean value of the ratio computed for every pixels of each 2-d slice.
-        Can be used as a metric to quantify the focus level this slice. Shape
-        is (z,) for a 3-d image.
-
-    """
-    # apply focus_measurement_2d for each z-slice
-    l_ratio = []
-    l_focus = []
-    for z in range(image.shape[0]):
-        ratio, global_focus = _focus_measurement_2d(image[z],
-                                                    neighborhood_size)
-        l_ratio.append(ratio)
-        l_focus.append(global_focus)
+                ndim=3,
+                dtype=[np.uint8, np.uint16, np.float32, np.float64])
 
-    # get a 3-d results
-    ratio = np.stack(l_ratio)
-    global_focus = np.stack(l_focus)
+    # select and keep best z-slices
+    indices_to_keep = get_in_focus_indices(focus, proportion)
+    in_focus_image = image[indices_to_keep]
 
-    return ratio, global_focus
+    return in_focus_image
 
 
-def get_in_focus_indices(global_focus, proportion):
+def get_in_focus_indices(focus, proportion):
     """ Select the best in-focus z-slices.
 
+    Helmli and Scherer’s mean method is used as a focus metric.
+
     Parameters
     ----------
-    global_focus : np.ndarray, np.float32
-        Mean value of the ratio computed for every pixels of each 2-d slice.
-        Can be used as a metric to quantify the focus level this slice. Shape
-        is (z,) for a 3-d image or () for a 2-d image.
+    focus : np.ndarray, np.float64
+        A 3-d tensor with a focus metric computed for each pixel of the
+        original image. See :func:`bigfish.stack.compute_focus`.
     proportion : float or int
         Proportion of z-slices to keep (float between 0 and 1) or number of
         z-slices to keep (positive integer).
@@ -443,61 +272,22 @@ def get_in_focus_indices(global_focus, proportion):
 
     """
     # check parameters
-    check_parameter(global_focus=(np.ndarray, np.float32),
-                    proportion=(float, int))
-    check_array(global_focus, ndim=[0, 1], dtype=np.float32, allow_nan=False)
+    check_parameter(proportion=(float, int))
+    check_array(focus, ndim=3, dtype=np.float64)
     if isinstance(proportion, float) and 0 <= proportion <= 1:
-        n = int(len(global_focus) * proportion)
+        n = int(focus.shape[0] * proportion)
     elif isinstance(proportion, int) and 0 <= proportion:
         n = int(proportion)
     else:
         raise ValueError("'proportion' should be a float between 0 and 1 or a "
                          "positive integer, but not {0}.".format(proportion))
 
+    # measure focus level per 2-d slices
+    focus_levels = np.mean(focus, axis=(1, 2))
+
     # select the best z-slices
-    n = min(n, global_focus.size)
-    indices_to_keep = list(np.argsort(-global_focus)[:n])
+    n = min(n, focus_levels.size)
+    indices_to_keep = list(np.argsort(-focus_levels)[:n])
     indices_to_keep = sorted(indices_to_keep)
 
     return indices_to_keep
-
-
-def _one_hot_3d(indices, depth, return_boolean=False):
-    """Build a 3-d one-hot matrix from a 2-d indices matrix.
-
-    Parameters
-    ----------
-    indices : np.ndarray, int
-        A 2-d tensor with integer indices and shape (y, x).
-    depth : int
-        Depth of the 3-d one-hot matrix.
-    return_boolean : bool
-        Return a boolean one-hot encoded matrix.
-
-    Returns
-    -------
-    one_hot : np.ndarray
-        A 3-d binary tensor with shape (depth, y, x)
-
-    """
-    # check parameters
-    check_parameter(depth=int)
-    check_array(indices,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                       np.int8, np.int16, np.int32, np.int64])
-
-    # initialize the 3-d one-hot matrix
-    one_hot = np.zeros((indices.size, depth), dtype=indices.dtype)
-
-    # flatten the matrix to easily one-hot encode it, then reshape it
-    one_hot[np.arange(indices.size), indices.ravel()] = 1
-    one_hot.shape = indices.shape + (depth,)
-
-    # rearrange the axis
-    one_hot = np.moveaxis(one_hot, source=2, destination=0)
-
-    if return_boolean:
-        one_hot = one_hot.astype(bool)
-
-    return one_hot
diff --git a/bigfish/stack/quality.py b/bigfish/stack/quality.py
new file mode 100644
index 00000000..8e5ab61e
--- /dev/null
+++ b/bigfish/stack/quality.py
@@ -0,0 +1,131 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Functions used to detect and clean noisy images.
+"""
+
+import numpy as np
+
+from .utils import check_array, check_parameter, check_range_value
+from .filter import mean_filter
+
+
+# ### Focus ###
+
+def compute_focus(image, neighborhood_size=31):
+    """Helmli and Scherer’s mean method is used as a focus metric.
+
+    For each pixel yx in a 2-d image, we compute the ratio:
+
+    .. math::
+
+        R(y, x) = \\left \\{ \\begin{array}{rcl} \\frac{I(y, x)}{\\mu(y, x)} &
+        \\mbox{if} & I(y, x) \\ge \\mu(y, x) \\\ \\frac{\\mu(y, x)}{I(y, x)} &
+        \\mbox{otherwise} & \\end{array} \\right.
+
+    with :math:`I(y, x)` the intensity of the pixel yx and :math:`\\mu(y, x)`
+    the mean intensity of the pixels in its neighborhood.
+
+    For a 3-d image, we compute this metric for each z surface.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        A 2-d or 3-d image with shape (y, x) or (z, y, x).
+    neighborhood_size : int
+        The size of the square used to define the neighborhood of each pixel.
+        An odd value is preferred.
+
+    Returns
+    -------
+    focus : np.ndarray, np.float64
+        A 2-d or 3-d tensor with the R(y, x) computed for each pixel of the
+        original image.
+
+    """
+    # check parameters
+    check_array(image,
+                ndim=[2, 3],
+                dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    check_parameter(neighborhood_size=(int, tuple, list))
+    check_range_value(image, min_=0)
+
+    # cast image in float if necessary
+    if image.dtype in [np.uint8, np.uint16]:
+        image_float = image.astype(np.float64)
+    else:
+        image_float = image
+
+    # build kernel
+    if image.ndim == 3:
+        focus = _compute_focus_3d(image_float, neighborhood_size)
+    else:
+        focus = _compute_focus_2d(image_float, neighborhood_size)
+
+    return focus
+
+
+def _compute_focus_3d(image_3d, kernel_size):
+    """Compute a pixel-wise focus metric for a 3-d image.
+
+    Parameters
+    ----------
+    image_3d : np.ndarray, np.float
+        A 3-d image with shape (z, y, x).
+    kernel_size : int
+        The size of the square used to define the neighborhood of each pixel.
+        An odd value is preferred.
+
+    Returns
+    -------
+    focus : np.ndarray, np.float64
+        A 3-d tensor with the R(z, y, x) computed for each pixel of the
+        original image.
+
+    """
+    # compute focus metric for each z surface
+    focus = []
+    for image_2d in image_3d:
+        focus_2d = _compute_focus_2d(image_2d, kernel_size)
+        focus.append(focus_2d)
+
+    #  stack focus metrics
+    focus = np.stack(focus)
+
+    return focus
+
+
+def _compute_focus_2d(image_2d, kernel_size):
+    """Compute a pixel-wise focus metric for a 2-d image.
+
+    Parameters
+    ----------
+    image_2d : np.ndarray, np.float
+        A 2-d image with shape (y, x).
+    kernel_size : int
+        The size of the square used to define the neighborhood of each pixel.
+        An odd value is preferred.
+
+    Returns
+    -------
+    focus : np.ndarray, np.float64
+        A 2-d tensor with the R(y, x) computed for each pixel of the original
+        image.
+
+    """
+    # mean filtered image
+    image_filtered_mean = mean_filter(image_2d, "square", kernel_size)
+
+    # compute focus metric
+    ratio_default = np.ones_like(image_2d, dtype=np.float64)
+    ratio_1 = np.divide(image_2d, image_filtered_mean,
+                        out=ratio_default,
+                        where=image_filtered_mean > 0)
+    ratio_2 = np.divide(image_filtered_mean, image_2d,
+                        out=ratio_default,
+                        where=image_2d > 0)
+    focus = np.where(image_2d >= image_filtered_mean, ratio_1, ratio_2)
+
+    return focus
diff --git a/bigfish/stack/utils.py b/bigfish/stack/utils.py
index 475bf8fb..48307a1f 100644
--- a/bigfish/stack/utils.py
+++ b/bigfish/stack/utils.py
@@ -65,9 +65,6 @@ def _check_features_df(df, features):
     features : List[str]
         Names of the expected features.
 
-    Returns
-    -------
-
     """
     # check columns
     if not set(features).issubset(df.columns):
@@ -75,8 +72,6 @@ def _check_features_df(df, features):
                          "features. {0} instead of {1}"
                          .format(list(df.columns.values), features))
 
-    return
-
 
 def _check_nan_df(df, features_to_check=None):
     """Check specific columns of the dataframe do not have any missing values.
@@ -88,9 +83,6 @@ def _check_nan_df(df, features_to_check=None):
     features_to_check : List[str]
         Names of the checked features.
 
-    Returns
-    -------
-
     """
     # count NaN
     nan_count = df.isnull().sum()
@@ -109,8 +101,6 @@ def _check_nan_df(df, features_to_check=None):
             raise ValueError("The dataframe has {0} NaN values for the "
                              "requested features: \n{1}.".format(x, nan_count))
 
-    return
-
 
 # ### Sanity checks array ###
 
@@ -165,20 +155,22 @@ def _check_dtype_array(array, dtype):
     dtype : type or List[type]
         Type expected.
 
-    Returns
-    -------
-
     """
     # enlist the dtype expected
     if isinstance(dtype, type):
         dtype = [dtype]
 
+    # TODO simplify
     # check the dtype of the array
+    error = True
     for dtype_expected in dtype:
         if array.dtype == dtype_expected:
-            return
-    raise TypeError("{0} is not supported yet. Use one of those dtypes "
-                    "instead: {1}.".format(array.dtype, dtype))
+            error = False
+            break
+
+    if error:
+        raise TypeError("{0} is not supported yet. Use one of those dtypes "
+                        "instead: {1}.".format(array.dtype, dtype))
 
 
 def _check_dim_array(array, ndim):
@@ -191,9 +183,6 @@ def _check_dim_array(array, ndim):
     ndim : int or List[int]
         Number of dimensions expected
 
-    Returns
-    -------
-
     """
     # enlist the number of expected dimensions
     if isinstance(ndim, int):
@@ -213,9 +202,6 @@ def _check_nan_array(array):
     array : np.ndarray
         Array to check.
 
-    Returns
-    -------
-
     """
     # count nan
     mask = np.isnan(array)
@@ -268,7 +254,7 @@ def check_recipe(recipe, data_directory=None):
     recipe : dict
         Map the images according to their field of view, their round,
         their channel and their spatial dimensions. Can only contain the keys
-        'pattern', 'fov', 'r', 'c', 'z', 'ext' or 'opt'.
+        `pattern`, `fov`, `r`, `c`, `z`, `ext` or `opt`.
     data_directory : str
         Path of the directory with the files describes in the recipe. If it is
         provided, the function check that the files exist.
@@ -335,8 +321,8 @@ def check_recipe(recipe, data_directory=None):
 def fit_recipe(recipe):
     """Fit a recipe.
 
-    Fitting a recipe consists in wrapping every values of 'fov', 'r', 'c' and
-    'z' in a list (an empty one if necessary). Values for 'ext' and 'opt' are
+    Fitting a recipe consists in wrapping every values of `fov`, `r`, `c` and
+    `z` in a list (an empty one if necessary). Values for `ext` and `opt` are
     also initialized.
 
     Parameters
@@ -344,14 +330,14 @@ def fit_recipe(recipe):
     recipe : dict
         Map the images according to their field of view, their round,
         their channel and their spatial dimensions. Can only contain the keys
-        'pattern', 'fov', 'r', 'c', 'z', 'ext' or 'opt'.
+        `pattern`, `fov`, `r`, `c`, `z`, `ext` or `opt`.
 
     Returns
     -------
     new_recipe : dict
         Map the images according to their field of view, their round,
         their channel and their spatial dimensions. Contain the keys
-        'pattern', 'fov', 'r', 'c', 'z', 'ext' and 'opt', initialized if
+        `pattern`, `fov`, `r`, `c`, `z`, `ext` and `opt`, initialized if
         necessary.
 
     """
@@ -380,8 +366,8 @@ def fit_recipe(recipe):
 def _is_recipe_fitted(recipe):
     """Check if a recipe is ready to be used.
 
-    Fitting a recipe consists in wrapping every values of 'fov', 'r', 'c' and
-    'z' in a list (an empty one if necessary). Values for 'ext' and 'opt' are
+    Fitting a recipe consists in wrapping every values of `fov`, `r`, `c` and
+    `z` in a list (an empty one if necessary). Values for `ext` and `opt` are
     also initialized.
 
     Parameters
@@ -389,7 +375,7 @@ def _is_recipe_fitted(recipe):
     recipe : dict
         Map the images according to their field of view, their round,
         their channel and their spatial dimensions. Can only contain the keys
-        'pattern', 'fov', 'r', 'c', 'z', 'ext' or 'opt'.
+        `pattern`, `fov`, `r`, `c`, `z`, `ext` or `opt`.
 
     Returns
     -------
@@ -419,17 +405,17 @@ def get_path_from_recipe(recipe, input_folder, fov=0, r=0, c=0, z=0):
     recipe : dict
         Map the images according to their field of view, their round,
         their channel and their spatial dimensions. Only contain the keys
-        'pattern', 'fov', 'r', 'c', 'z', 'ext' or 'opt'.
+        `pattern`, `fov`, `r`, `c`, `z`, `ext` or `opt`.
     input_folder : str
         Path of the folder containing the images.
     fov : int
-        Index of the 'fov' element in the recipe to use in the filename.
+        Index of the `fov` element in the recipe to use in the filename.
     r : int
-        Index of the 'r' element in the recipe to use in the filename.
+        Index of the `r` element in the recipe to use in the filename.
     c : int
-        Index of the 'c' element in the recipe to use in the filename.
+        Index of the `c` element in the recipe to use in the filename.
     z : int
-        Index of the 'z' element in the recipe to use in the filename.
+        Index of the `z` element in the recipe to use in the filename.
 
     Returns
     -------
@@ -483,15 +469,15 @@ def get_path_from_recipe(recipe, input_folder, fov=0, r=0, c=0, z=0):
 
 
 def get_nb_element_per_dimension(recipe):
-    """Count the number of element to stack for each dimension ('r', 'c'
-    and 'z').
+    """Count the number of element to stack for each dimension (`r`, `c`
+    and `z`).
 
     Parameters
     ----------
     recipe : dict
         Map the images according to their field of view, their round,
         their channel and their spatial dimensions. Only contain the keys
-        'fov', 'r', 'c', 'z', 'ext' or 'opt'.
+        `fov`, `r`, `c`, `z`, `ext` or `opt`.
 
     Returns
     -------
@@ -522,7 +508,7 @@ def count_nb_fov(recipe):
     recipe : dict
         Map the images according to their field of view, their round,
         their channel and their spatial dimensions. Can only contain the keys
-        'pattern', 'fov', 'r', 'c', 'z', 'ext' or 'opt'.
+        `pattern`, `fov`, `r`, `c`, `z`, `ext` or `opt`.
 
     Returns
     -------
@@ -662,6 +648,8 @@ def load_and_save_url(remote_url, directory, filename=None):
 
     Returns
     -------
+    path : str
+        Path of the downloaded file.
 
     """
     # check parameters
@@ -677,7 +665,7 @@ def load_and_save_url(remote_url, directory, filename=None):
     # download and save data
     urlretrieve(remote_url, path)
 
-    return
+    return path
 
 
 def check_hash(path, expected_hash):
@@ -747,13 +735,15 @@ def compute_hash(path):
     return sha256
 
 
-def check_input_data(input_directory):
+def check_input_data(input_directory, input_segmentation=False):
     """Check input images exists and download them if necessary.
 
-    Returns
-    -------
+    Parameters
+    ----------
     input_directory : str
-        Path of the input data directory.
+        Path of the image directory.
+    input_segmentation : bool
+        Check 2-d example images for segmentation.
 
     """
     # parameters
@@ -763,6 +753,12 @@ def check_input_data(input_directory):
     filename_input_smfish = "experiment_1_smfish_fov_1.tif"
     url_input_smfish = "https://github.com/fish-quant/big-fish-examples/releases/download/data/experiment_1_smfish_fov_1.tif"
     hash_input_smfish = "bc6aec1f3da4c25f3c6b579c274584ce1e88112c7f980e5437b5ad5223bc8ff6"
+    filename_input_nuc_full = "example_nuc_full.tif"
+    url_input_nuc_full = "https://github.com/fish-quant/big-fish-examples/releases/download/data/example_nuc_full.tif"
+    hash_input_nuc_full = "3bf70c7b5a02c60725baba3dfddff3010e0957de9ab78f0f65166248ead84ec4"
+    filename_input_cell_full = "example_cell_full.tif"
+    url_input_cell_full = "https://github.com/fish-quant/big-fish-examples/releases/download/data/example_cell_full.tif"
+    hash_input_cell_full = "36981955ed97e9cab8a69241140a9aac3bdcf32dc157d6957fd37edcb16b34bd"
 
     # check if input dapi image exists
     path = os.path.join(input_directory, filename_input_dapi)
@@ -818,7 +814,62 @@ def check_input_data(input_directory):
                           filename_input_smfish)
         check_hash(path, hash_input_smfish)
 
-    return
+    # stop here or check segmentation examples
+    if input_segmentation:
+
+        # check if example nucleus exists
+        path = os.path.join(input_directory, filename_input_nuc_full)
+        if os.path.isfile(path):
+
+            # check that image is not corrupted
+            try:
+                check_hash(path, hash_input_nuc_full)
+                print("{0} is already in the directory"
+                      .format(filename_input_nuc_full))
+
+            # otherwise download it
+            except IOError:
+                print("{0} seems corrupted".format(filename_input_nuc_full))
+                print("downloading {0}...".format(filename_input_nuc_full))
+                load_and_save_url(url_input_nuc_full,
+                                  input_directory,
+                                  filename_input_nuc_full)
+                check_hash(path, hash_input_nuc_full)
+
+        # if file does not exist we directly download it
+        else:
+            print("downloading {0}...".format(filename_input_nuc_full))
+            load_and_save_url(url_input_nuc_full,
+                              input_directory,
+                              filename_input_nuc_full)
+            check_hash(path, hash_input_nuc_full)
+
+        # check if example cell exists
+        path = os.path.join(input_directory, filename_input_cell_full)
+        if os.path.isfile(path):
+
+            # check that image is not corrupted
+            try:
+                check_hash(path, hash_input_cell_full)
+                print("{0} is already in the directory"
+                      .format(filename_input_cell_full))
+
+            # otherwise download it
+            except IOError:
+                print("{0} seems corrupted".format(filename_input_cell_full))
+                print("downloading {0}...".format(filename_input_cell_full))
+                load_and_save_url(url_input_cell_full,
+                                  input_directory,
+                                  filename_input_cell_full)
+                check_hash(path, hash_input_cell_full)
+
+        # if file does not exist we directly download it
+        else:
+            print("downloading {0}...".format(filename_input_cell_full))
+            load_and_save_url(url_input_cell_full,
+                              input_directory,
+                              filename_input_cell_full)
+            check_hash(path, hash_input_cell_full)
 
 
 # ### Computation ###
@@ -835,6 +886,8 @@ def moving_average(array, n):
 
     Returns
     -------
+    results : np.ndarray
+        Moving average values.
 
     """
     # check parameter
@@ -843,15 +896,15 @@ def moving_average(array, n):
 
     # compute moving average
     cumsum = [0]
-    res = []
+    results = []
     for i, x in enumerate(array, 1):
         cumsum.append(cumsum[i-1] + x)
         if i >= n:
             ma = (cumsum[i] - cumsum[i - n]) / n
-            res.append(ma)
-    res = np.array(res)
+            results.append(ma)
+    results = np.array(results)
 
-    return res
+    return results
 
 
 def centered_moving_average(array, n):
@@ -866,6 +919,8 @@ def centered_moving_average(array, n):
 
     Returns
     -------
+    results : np.ndarray
+        Centered moving average values.
 
     """
     # check parameter
@@ -881,6 +936,90 @@ def centered_moving_average(array, n):
     array_padded = np.pad(array, pad_width=r, mode="reflect")
 
     # compute centered moving average
-    res = moving_average(array_padded, n)
+    results = moving_average(array_padded, n)
+
+    return results
+
+
+# ### Spot utilities ###
+
+def get_sigma(voxel_size_z=None, voxel_size_yx=100, psf_z=None, psf_yx=200):
+    """Compute the standard deviation of the PSF of the spots.
+
+    Parameters
+    ----------
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, we consider
+        a 2-d PSF.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, we consider a 2-d PSF.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
+
+    Returns
+    -------
+    sigma : Tuple[float]
+        Standard deviations in pixel of the PSF, one element per dimension.
+
+    """
+    # check parameters
+    check_parameter(voxel_size_z=(int, float, type(None)),
+                    voxel_size_yx=(int, float),
+                    psf_z=(int, float, type(None)),
+                    psf_yx=(int, float))
+
+    # compute sigma
+    sigma_yx = psf_yx / voxel_size_yx
+
+    if voxel_size_z is None or psf_z is None:
+        return sigma_yx, sigma_yx
+
+    else:
+        sigma_z = psf_z / voxel_size_z
+        return sigma_z, sigma_yx, sigma_yx
+
+
+def get_radius(voxel_size_z=None, voxel_size_yx=100, psf_z=None, psf_yx=200):
+    """Approximate the radius of the detected spot.
+
+    We use the formula:
+
+    .. math::
+
+        \\mbox{radius} = \\mbox{sqrt(ndim)} * \\sigma
+
+    with :math:`\\mbox{ndim}` the number of dimension of the image and
+    :math:`\\sigma` the standard deviation (in pixel) of the detected spot.
+
+    Parameters
+    ----------
+    voxel_size_z : int or float or None
+        Height of a voxel, along the z axis, in nanometer. If None, we consider
+        a 2-d spot.
+    voxel_size_yx : int or float
+        Size of a voxel on the yx plan, in nanometer.
+    psf_z : int or float or None
+        Theoretical size of the PSF emitted by a spot in the z plan, in
+        nanometer. If None, we consider a 2-d spot.
+    psf_yx : int or float
+        Theoretical size of the PSF emitted by a spot in the yx plan, in
+        nanometer.
+
+    Returns
+    -------
+    radius : Tuple[float]
+        Radius in pixels of the detected spots, one element per dimension.
+
+    """
+    # compute sigma
+    sigma = get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
+
+    # compute radius
+    radius = [np.sqrt(len(sigma)) * sigma_ for sigma_ in sigma]
+    radius = tuple(radius)
 
-    return res
+    return radius
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 00000000..d0c3cbf1
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = source
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 00000000..6247f7e2
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/source/classification/extraction.rst b/docs/source/classification/extraction.rst
new file mode 100644
index 00000000..ae8dc654
--- /dev/null
+++ b/docs/source/classification/extraction.rst
@@ -0,0 +1,65 @@
+.. _extraction overview:
+
+Single-cell identification
+**************************
+
+.. currentmodule:: bigfish.stack
+
+Functions to exploit detection and segmentation results, by identifying
+individual cells and their objects.
+
+Identify and remove transcription sites
+=======================================
+
+Define transcription sites as clustered RNAs detected inside nucleus:
+
+* :func:`bigfish.stack.remove_transcription_site`
+
+More generally, identify detected objects within a specific cellular region:
+
+* :func:`bigfish.stack.identify_objects_in_region`
+
+.. autofunction:: remove_transcription_site
+.. autofunction:: identify_objects_in_region
+
+------------
+
+Define and export single-cell results
+=====================================
+
+Extract detection and segmentation results and for every individual cell:
+
+* :func:`bigfish.stack.extract_cell`
+* :func:`bigfish.stack.extract_spots_from_frame`
+* :func:`bigfish.stack.summarize_extraction_results`
+
+See an example of application `here <http://localhost:8888/notebooks/big-fish-
+examples/notebooks/6%20-%20Extract%20cell%20level%20results.ipynb/>`_.
+
+.. autofunction:: extract_cell
+.. autofunction:: extract_spots_from_frame
+.. autofunction:: summarize_extraction_results
+
+------------
+
+Manipulate surfaces, coordinates and boundaries
+===============================================
+
+Convert identified surfaces into coordinates, delimit boundaries and
+manipulates coordinates:
+
+* :func:`bigfish.stack.center_mask_coord`
+* :func:`bigfish.stack.from_boundaries_to_surface`
+* :func:`bigfish.stack.from_surface_to_boundaries`
+* :func:`bigfish.stack.from_binary_to_coord`
+* :func:`bigfish.stack.complete_coord_boundaries`
+* :func:`bigfish.stack.from_coord_to_frame`
+* :func:`bigfish.stack.from_coord_to_surface`
+
+.. autofunction:: center_mask_coord
+.. autofunction:: from_boundaries_to_surface
+.. autofunction:: from_surface_to_boundaries
+.. autofunction:: from_binary_to_coord
+.. autofunction:: complete_coord_boundaries
+.. autofunction:: from_coord_to_frame
+.. autofunction:: from_coord_to_surface
diff --git a/docs/source/classification/features.rst b/docs/source/classification/features.rst
new file mode 100644
index 00000000..d23a50c0
--- /dev/null
+++ b/docs/source/classification/features.rst
@@ -0,0 +1,37 @@
+.. _features overview:
+
+Features engineering
+********************
+
+.. currentmodule:: bigfish.classification
+
+Functions to compute features about cell morphology and RNAs localization.
+There are two main functions to compute spatial and morphological features are:
+
+* :func:`bigfish.classification.compute_features`
+* :func:`bigfish.classification.get_features_name`
+
+Group of features can be computed separately:
+
+* :func:`bigfish.classification.features_distance`
+* :func:`bigfish.classification.features_in_out_nucleus`
+* :func:`bigfish.classification.features_protrusion`
+* :func:`bigfish.classification.features_dispersion`
+* :func:`bigfish.classification.features_topography`
+* :func:`bigfish.classification.features_foci`
+* :func:`bigfish.classification.features_area`
+* :func:`bigfish.classification.features_centrosome`
+
+See an example of application `here <http://localhost:8888/notebooks/big-fish-
+examples/notebooks/7%20-%20Analyze%20coordinates.ipynb/>`_.
+
+.. autofunction:: compute_features
+.. autofunction:: get_features_name
+.. autofunction:: features_distance
+.. autofunction:: features_in_out_nucleus
+.. autofunction:: features_protrusion
+.. autofunction:: features_dispersion
+.. autofunction:: features_topography
+.. autofunction:: features_foci
+.. autofunction:: features_area
+.. autofunction:: features_centrosome
diff --git a/docs/source/classification/input.rst b/docs/source/classification/input.rst
new file mode 100644
index 00000000..826d1db3
--- /dev/null
+++ b/docs/source/classification/input.rst
@@ -0,0 +1,11 @@
+.. _classification_input overview:
+
+Input coordinates preparation
+*****************************
+
+.. currentmodule:: bigfish.classification
+
+Format input coordinates and compute intermediary results to prepare features
+computation:
+
+.. autofunction:: prepare_extracted_data
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 00000000..0440f547
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,67 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+
+import os
+import sys
+import sphinx_rtd_theme
+
+# import Big-FISH
+sys.path.insert(0, os.path.abspath('../..'))
+import bigfish
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'big-fish'
+copyright = '2020, Arthur Imbert'
+author = 'Arthur Imbert'
+
+# The short X.Y version
+version = ".".join(bigfish.__version__.split(".")[:2])
+
+# The full version, including alpha/beta/rc tags
+release = bigfish.__version__
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'sphinx.ext.autodoc',
+    'sphinx.ext.autosummary',
+    'numpydoc',
+    'sphinx_rtd_theme',
+    'sphinx.ext.autosectionlabel'
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = []
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_rtd_theme'
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
diff --git a/docs/source/detection/cluster.rst b/docs/source/detection/cluster.rst
new file mode 100644
index 00000000..3e8ed7ea
--- /dev/null
+++ b/docs/source/detection/cluster.rst
@@ -0,0 +1,14 @@
+.. _cluster overview:
+
+Cluster detection
+************************
+
+.. currentmodule:: bigfish.detection
+
+Function to cluster spots in point cloud and detect relevant aggregated
+structures. A DBSCAN algorithm is performed.
+
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/blob/master/notebooks/5%20-%20Detect%20spots.ipynb/>`_.
+
+.. autofunction:: detect_clusters
diff --git a/docs/source/detection/dense.rst b/docs/source/detection/dense.rst
new file mode 100644
index 00000000..21233544
--- /dev/null
+++ b/docs/source/detection/dense.rst
@@ -0,0 +1,57 @@
+.. _dense overview:
+
+Dense regions decomposition
+***************************
+
+.. currentmodule:: bigfish.detection
+
+Functions to detect dense and bright regions (with potential clustered spots),
+then use gaussian simulations to correct a misdetection in these regions.
+
+Decompose and simulate dense regions
+====================================
+
+The main function to decompose dense regions is:
+
+* :func:`bigfish.detection.decompose_dense`
+
+It is also possible to perform the main steps of this decomposition separately:
+
+* :func:`bigfish.detection.get_dense_region`
+* :func:`bigfish.detection.simulate_gaussian_mixture`
+
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/blob/master/notebooks/5%20-%20Detect%20spots.ipynb/>`_.
+
+.. autofunction:: decompose_dense
+.. autofunction:: get_dense_region
+.. autofunction:: simulate_gaussian_mixture
+
+------------
+
+Modelize a reference spot
+=========================
+
+To simulate additional spots in the dense regions it is necessary to:
+
+#. Build a reference spot.
+
+    * :func:`bigfish.detection.build_reference_spot`
+
+#. Modelize this reference spot by fitting gaussian parameters.
+
+    * :func:`bigfish.detection.modelize_spot`
+
+#. Simulate gaussian signal.
+
+    * :func:`bigfish.detection.precompute_erf`
+    * :func:`bigfish.detection.initialize_grid`
+    * :func:`bigfish.detection.gaussian_2d`
+    * :func:`bigfish.detection.gaussian_3d`
+
+.. autofunction:: build_reference_spot
+.. autofunction:: modelize_spot
+.. autofunction:: precompute_erf
+.. autofunction:: initialize_grid
+.. autofunction:: gaussian_2d
+.. autofunction:: gaussian_3d
diff --git a/docs/source/detection/spots.rst b/docs/source/detection/spots.rst
new file mode 100644
index 00000000..c0f9aa5c
--- /dev/null
+++ b/docs/source/detection/spots.rst
@@ -0,0 +1,59 @@
+.. _spots overview:
+
+Automated spot detection
+************************
+
+.. currentmodule:: bigfish.detection
+
+Functions used to detect spots in a 2D or 3D image. Detection is performed in
+three steps:
+
+#. Image is denoised and spots are enhanced by using a Laplacian of Gaussian
+   (LoG) filter.
+#. Peaks are detected in the filtered image with a local maximum detection
+   algorithm.
+#. An intensity threshold is applied to discriminate actual spots from noisy
+   background.
+
+Detect spots
+============
+
+The main function for spot detection is:
+
+* :func:`bigfish.detection.detect_spots`
+
+It is also possible to perform the main steps of the spot detection separately:
+
+* :func:`bigfish.detection.local_maximum_detection`
+* :func:`bigfish.detection.spots_thresholding`
+
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/blob/master/notebooks/5%20-%20Detect%20spots.ipynb/>`_.
+
+.. autofunction:: detect_spots
+.. autofunction:: local_maximum_detection
+.. autofunction:: spots_thresholding
+
+------------
+
+Find a threshold (automatically)
+================================
+
+The need to set an appropriate threshold for each image is a real bottleneck
+that limits the possibility to scale a spot detection. Our method includes a
+heuristic function to to automatically set this threshold:
+
+* :func:`bigfish.detection.automated_threshold_setting`
+* :func:`bigfish.detection.get_elbow_values`
+
+.. autofunction:: automated_threshold_setting
+.. autofunction:: get_elbow_values
+
+------------
+
+Compute signal-to-noise ratio
+=============================
+
+Compute a signal-to-noise ratio (SNR) for the image:
+
+.. autofunction:: compute_snr_spots
diff --git a/docs/source/detection/subpixel.rst b/docs/source/detection/subpixel.rst
new file mode 100644
index 00000000..b29b4385
--- /dev/null
+++ b/docs/source/detection/subpixel.rst
@@ -0,0 +1,11 @@
+.. _subpixel overview:
+
+Subpixel fitting
+****************
+
+.. currentmodule:: bigfish.detection
+
+Function to detect individual spot coordinate with a subpixel accuracy,
+fitting a gaussian signal.
+
+.. autofunction:: fit_subpixel
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 00000000..7e68302b
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,133 @@
+.. big-fish documentation master file, created by
+   sphinx-quickstart on Thu Nov 19 22:45:09 2020.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+Big-FISH
+********
+
+Getting started
+===============
+
+To avoid dependency conflicts, we recommend the the use of a dedicated
+`virtual <https://docs.python.org/3.6/library/venv.html/>`_ or `conda
+<https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-
+environments.html/>`_ environment.  In a terminal run the command:
+
+.. code-block:: bash
+
+   $ conda create -n bigfish_env python=3.6
+   $ source activate bigfish_env
+
+We recommend two options to then install Big-FISH in your virtual environment.
+
+Download the package from PyPi
+------------------------------
+
+Use the package manager `pip <https://pip.pypa.io/en/stable/>`_ to install
+Big-FISH. In a terminal run the command:
+
+.. code-block:: bash
+
+   $ pip install big-fish
+
+Clone package from Github
+-------------------------
+
+Clone the project's `Github repository <https://github.com/fish-quant/big-
+fish/>`_ and install it manually with the following commands:
+
+.. code-block:: bash
+
+   $ git clone git@github.com:fish-quant/big-fish.git
+   $ cd big-fish
+   $ pip install .
+
+------------
+
+Examples
+========
+
+Several examples are available as `Jupyter notebooks <https://github.com/fish-
+quant/big-fish-examples/tree/master/notebooks/>`_:
+
+#. Read and write images.
+#. Normalize and filter images.
+#. Project in two dimensions.
+#. Segment nuclei and cells.
+#. Detect spots.
+#. Extract cell level results.
+#. Analyze coordinates.
+
+You can also run these example online with `mybinder <https://mybinder.org/v2/
+gh/fish-quant/fq-imjoy/binder?urlpath=git-pull%3Frepo%3Dhttps%253A%252F%252F
+github.com%252Ffish-quant%252Fbig-fish-examples%26urlpath%3Dtree%252Fbig-fish-
+examples%252Fnotebooks%26branch%3Dmaster/>`_. The remote server can take a bit
+of time to start.
+
+------------
+
+API reference
+*************
+
+.. toctree::
+   :caption: Preprocessing
+
+   stack/io
+   stack/preprocessing
+   stack/augmentation
+
+------------
+
+.. toctree::
+   :caption: Spot detection
+
+   detection/spots
+   detection/dense
+   detection/subpixel
+   detection/cluster
+
+------------
+
+.. toctree::
+   :caption: Segmentation
+
+   segmentation/thresholding
+   segmentation/input
+   segmentation/nucleus
+   segmentation/cell
+   segmentation/postprocessing
+
+------------
+
+.. toctree::
+   :caption: Analysis
+
+   classification/extraction
+   classification/input
+   classification/features
+
+
+------------
+
+.. toctree::
+   :caption: Visualization
+
+   plot/plot_image
+   plot/plot_detection
+   plot/plot_segmentation
+   plot/plot_coordinate
+
+
+------------
+
+* :ref:`Utility functions<utils overview>`: Sanity checks, get constant values,
+  compute hash, save plots, etc...
+
+------------
+
+Support
+=======
+
+If you have any question relative to the package, please open an `issue
+<https://github.com/fish-quant/big-fish/issues/>`_ on Github.
\ No newline at end of file
diff --git a/docs/source/plot/plot_coordinate.rst b/docs/source/plot/plot_coordinate.rst
new file mode 100644
index 00000000..86de7455
--- /dev/null
+++ b/docs/source/plot/plot_coordinate.rst
@@ -0,0 +1,10 @@
+.. _plot_coordinate overview:
+
+Single-cell plot
+****************
+
+.. currentmodule:: bigfish.plot
+
+Function to visualize coordinate representation of individual cells:
+
+.. autofunction:: plot_cell
diff --git a/docs/source/plot/plot_detection.rst b/docs/source/plot/plot_detection.rst
new file mode 100644
index 00000000..8287ccb2
--- /dev/null
+++ b/docs/source/plot/plot_detection.rst
@@ -0,0 +1,24 @@
+.. _plot_detection overview:
+
+Detection plots
+***************
+
+.. currentmodule:: bigfish.plot
+
+Functions to visualize detection results.
+
+Visualize detected spots:
+
+* :func:`bigfish.plot.plot_detection`
+
+Visualize the reference spot computed for an image:
+
+* :func:`bigfish.plot.plot_reference_spot`
+
+Visualize the elbow curve used to automatically set a detection threshold:
+
+* :func:`bigfish.plot.plot_elbow`
+
+.. autofunction:: plot_detection
+.. autofunction:: plot_reference_spot
+.. autofunction:: plot_elbow
diff --git a/docs/source/plot/plot_image.rst b/docs/source/plot/plot_image.rst
new file mode 100644
index 00000000..eabefc1b
--- /dev/null
+++ b/docs/source/plot/plot_image.rst
@@ -0,0 +1,28 @@
+.. _plot_image overview:
+
+Field of view plots
+*******************
+
+.. currentmodule:: bigfish.plot
+
+Functions to visualize 2D and 3D images:
+
+Plot images
+===========
+
+Plot 2D images or slices of 3D images:
+
+* :func:`bigfish.plot.plot_yx`
+* :func:`bigfish.plot.plot_images`
+
+.. autofunction:: plot_yx
+.. autofunction:: plot_images
+
+------------
+
+Plot quality measure of an image
+================================
+
+Plot focus measures for a 3D image:
+
+.. autofunction:: plot_sharpness
diff --git a/docs/source/plot/plot_segmentation.rst b/docs/source/plot/plot_segmentation.rst
new file mode 100644
index 00000000..37c3cb7e
--- /dev/null
+++ b/docs/source/plot/plot_segmentation.rst
@@ -0,0 +1,21 @@
+.. _plot_segmentation overview:
+
+Segmentation plots
+******************
+
+.. currentmodule:: bigfish.plot
+
+Functions to visualize segmentation results.
+
+Visualize segmented instances:
+
+* :func:`bigfish.plot.plot_segmentation`
+* :func:`bigfish.plot.plot_segmentation_boundary`
+
+Compare segmented instances with a ground truth:
+
+* :func:`bigfish.plot.plot_segmentation_diff`
+
+.. autofunction:: plot_segmentation
+.. autofunction:: plot_segmentation_boundary
+.. autofunction:: plot_segmentation_diff
diff --git a/docs/source/segmentation/cell.rst b/docs/source/segmentation/cell.rst
new file mode 100644
index 00000000..dcf9aac9
--- /dev/null
+++ b/docs/source/segmentation/cell.rst
@@ -0,0 +1,46 @@
+.. _cell overview:
+
+Cell segmentation
+*****************
+
+.. currentmodule:: bigfish.segmentation
+
+Functions used to segment cells.
+
+Apply watershed algorithm
+=========================
+
+Main function to segment cells with a watershed algorithm:
+
+* :func:`bigfish.segmentation.cell_watershed`
+
+Our segmentation using watershed algorithm can also be perform with two
+separated steps:
+
+* :func:`bigfish.segmentation.get_watershed_relief`
+* :func:`bigfish.segmentation.apply_watershed`
+
+.. autofunction:: cell_watershed
+.. autofunction:: get_watershed_relief
+.. autofunction:: apply_watershed
+
+------------
+
+Apply a Unet-based model (distance map)
+=======================================
+
+Load a pretrained model:
+
+* :func:`bigfish.segmentation.unet_distance_edge_double`
+
+Segment cells:
+
+* :func:`bigfish.segmentation.apply_unet_distance_double`
+* :func:`bigfish.segmentation.from_distance_to_instances`
+
+See an example of application `here <https://github.com/fish-quant/big-fish-ex
+amples/blob/master/notebooks/4%20-%20Segment%20nuclei%20and%20cells.ipynb/>`_.
+
+.. autofunction:: unet_distance_edge_double
+.. autofunction:: apply_unet_distance_double
+.. autofunction:: from_distance_to_instances
diff --git a/docs/source/segmentation/input.rst b/docs/source/segmentation/input.rst
new file mode 100644
index 00000000..ded2bf72
--- /dev/null
+++ b/docs/source/segmentation/input.rst
@@ -0,0 +1,16 @@
+.. _segmentation_input overview:
+
+Input images preparation
+************************
+
+.. currentmodule:: bigfish.segmentation
+
+Resize, pad and normalize input images before feeding a deep learning model:
+
+* :func:`bigfish.segmentation.resize_image`
+* :func:`bigfish.segmentation.get_marge_padding`
+* :func:`bigfish.segmentation.compute_image_standardization`
+
+.. autofunction:: resize_image
+.. autofunction:: get_marge_padding
+.. autofunction:: compute_image_standardization
diff --git a/docs/source/segmentation/nucleus.rst b/docs/source/segmentation/nucleus.rst
new file mode 100644
index 00000000..5943e8af
--- /dev/null
+++ b/docs/source/segmentation/nucleus.rst
@@ -0,0 +1,37 @@
+.. _nucleus overview:
+
+Nucleus segmentation
+********************
+
+.. currentmodule:: bigfish.segmentation
+
+Functions used to segment nuclei.
+
+Apply a Unet-based model (3-classes)
+====================================
+
+Load a pretrained model:
+
+* :func:`bigfish.segmentation.unet_3_classes_nuc`
+
+Segment nuclei:
+
+* :func:`bigfish.segmentation.apply_unet_3_classes`
+* :func:`bigfish.segmentation.from_3_classes_to_instances`
+
+See an example of application `here <https://github.com/fish-quant/big-fish-ex
+amples/blob/master/notebooks/4%20-%20Segment%20nuclei%20and%20cells.ipynb/>`_.
+
+.. autofunction:: unet_3_classes_nuc
+.. autofunction:: apply_unet_3_classes
+.. autofunction:: from_3_classes_to_instances
+
+------------
+
+Remove segmented nuclei
+=======================
+
+Remove segmented nucleus instances to perform a new segmentation on the
+residual image:
+
+.. autofunction:: remove_segmented_nuc
diff --git a/docs/source/segmentation/postprocessing.rst b/docs/source/segmentation/postprocessing.rst
new file mode 100644
index 00000000..c5f70e91
--- /dev/null
+++ b/docs/source/segmentation/postprocessing.rst
@@ -0,0 +1,51 @@
+.. _segmentation_postprocessing overview:
+
+Postprocessing
+**************
+
+.. currentmodule:: bigfish.segmentation
+
+Functions used to clean and refine segmentation results.
+
+Label and clean instances
+=========================
+
+Label disconnected instances:
+
+* :func:`bigfish.segmentation.label_instances`
+* :func:`bigfish.segmentation.merge_labels`
+
+Clean segmentation results:
+
+* :func:`bigfish.segmentation.clean_segmentation`
+* :func:`bigfish.segmentation.remove_disjoint`
+
+Match nuclei and cells:
+
+* :func:`bigfish.segmentation.match_nuc_cell`
+
+See an example of application `here <https://github.com/fish-quant/big-fish-ex
+amples/blob/master/notebooks/4%20-%20Segment%20nuclei%20and%20cells.ipynb/>`_.
+
+.. autofunction:: label_instances
+.. autofunction:: merge_labels
+.. autofunction:: clean_segmentation
+.. autofunction:: remove_disjoint
+.. autofunction:: match_nuc_cell
+
+------------
+
+Compute instance statistics
+===========================
+
+Compute statistics for each segmented instance:
+
+* :func:`bigfish.segmentation.compute_mean_diameter`
+* :func:`bigfish.segmentation.compute_mean_convexity_ratio`
+* :func:`bigfish.segmentation.compute_surface_ratio`
+* :func:`bigfish.segmentation.count_instances`
+
+.. autofunction:: compute_mean_diameter
+.. autofunction:: compute_mean_convexity_ratio
+.. autofunction:: compute_surface_ratio
+.. autofunction:: count_instances
diff --git a/docs/source/segmentation/thresholding.rst b/docs/source/segmentation/thresholding.rst
new file mode 100644
index 00000000..a34aa994
--- /dev/null
+++ b/docs/source/segmentation/thresholding.rst
@@ -0,0 +1,10 @@
+.. _thresholding overview:
+
+Thresholding
+************
+
+.. currentmodule:: bigfish.segmentation
+
+Thresholding is the most standard and direct segmentation method:
+
+.. autofunction:: thresholding
diff --git a/docs/source/stack/augmentation.rst b/docs/source/stack/augmentation.rst
new file mode 100644
index 00000000..1e8fb259
--- /dev/null
+++ b/docs/source/stack/augmentation.rst
@@ -0,0 +1,33 @@
+.. _augmentation overview:
+
+Augmentation
+************
+
+.. currentmodule:: bigfish.stack
+
+Functions used to increase and diversify a dataset by duplicating and
+transforming images. Available transformations are:
+
+* Identity
+* Transpose
+* Inverse transpose
+* Horizontal flip
+* Vertical flip
+* 90° rotation
+* 180° rotation
+* 270° rotation
+
+Apply a random transformation on a 2D image:
+
+* :func:`bigfish.stack.augment_2d`
+* :func:`bigfish.stack.augment_2d_function`
+
+Apply all the possible transformations on a 2D image:
+
+* :func:`bigfish.stack.augment_8_times`
+* :func:`bigfish.stack.augment_8_times_reversed`
+
+.. autofunction:: augment_2d
+.. autofunction:: augment_2d_function
+.. autofunction:: augment_8_times
+.. autofunction:: augment_8_times_reversed
diff --git a/docs/source/stack/io.rst b/docs/source/stack/io.rst
new file mode 100644
index 00000000..e71c16c8
--- /dev/null
+++ b/docs/source/stack/io.rst
@@ -0,0 +1,59 @@
+.. _io overview:
+
+I/O operations
+**************
+
+.. currentmodule:: bigfish.stack
+
+Functions used to read data from various sources and store them in a numpy
+array.
+
+Read files
+==========
+
+Read image, video and numerical data as a numpy array:
+
+* :func:`bigfish.stack.read_image`
+* :func:`bigfish.stack.read_dv`
+* :func:`bigfish.stack.read_array`
+
+Read a zipped archive of files as a dictionary-like object:
+
+* :func:`bigfish.stack.read_uncompressed`
+* :func:`bigfish.stack.read_cell_extracted`
+
+Read CSV file:
+
+* :func:`bigfish.stack.read_array_from_csv`
+* :func:`bigfish.stack.read_dataframe_from_csv`
+
+.. autofunction:: read_image
+.. autofunction:: read_dv
+.. autofunction:: read_array
+.. autofunction:: read_uncompressed
+.. autofunction:: read_cell_extracted
+.. autofunction:: read_array_from_csv
+.. autofunction:: read_dataframe_from_csv
+
+------------
+
+Write files
+===========
+
+Save numpy array:
+
+* :func:`bigfish.stack.save_image`
+* :func:`bigfish.stack.save_array`
+
+Save cell-level results in a zipped archive of files:
+
+* :func:`bigfish.stack.save_cell_extracted`
+
+Save tabular data in a CSV file:
+
+* :func:`bigfish.stack.save_data_to_csv`
+
+.. autofunction:: save_image
+.. autofunction:: save_array
+.. autofunction:: save_cell_extracted
+.. autofunction:: save_data_to_csv
diff --git a/docs/source/stack/preprocessing.rst b/docs/source/stack/preprocessing.rst
new file mode 100644
index 00000000..c10ec8bd
--- /dev/null
+++ b/docs/source/stack/preprocessing.rst
@@ -0,0 +1,107 @@
+.. _preprocessing overview:
+
+Image preparation
+*****************
+
+.. currentmodule:: bigfish.stack
+
+Functions used to normalize, cast, project or filter images.
+
+Normalize images
+================
+
+Rescale or contrast pixel intensity:
+
+.. autofunction:: rescale
+
+------------
+
+Cast images
+===========
+
+Cast images to a specified dtype (with respect to the image range of values):
+
+* :func:`bigfish.stack.cast_img_uint8`
+* :func:`bigfish.stack.cast_img_uint16`
+* :func:`bigfish.stack.cast_img_float32`
+* :func:`bigfish.stack.cast_img_float64`
+
+.. autofunction:: cast_img_uint8
+.. autofunction:: cast_img_uint16
+.. autofunction:: cast_img_float32
+.. autofunction:: cast_img_float64
+
+------------
+
+Filter images
+=============
+
+Apply filtering transformations:
+
+* :func:`bigfish.stack.mean_filter`
+* :func:`bigfish.stack.median_filter`
+* :func:`bigfish.stack.gaussian_filter`
+* :func:`bigfish.stack.maximum_filter`
+* :func:`bigfish.stack.minimum_filter`
+* :func:`bigfish.stack.dilation_filter`
+* :func:`bigfish.stack.erosion_filter`
+
+Use Laplacian of Gaussian (LoG) filter to enhance peak signals and denoise the
+rest of the image:
+
+* :func:`bigfish.stack.log_filter`
+
+Use blurring filters with large kernel to estimate and remove background
+signal:
+
+* :func:`bigfish.stack.remove_background_mean`
+* :func:`bigfish.stack.remove_background_gaussian`
+
+.. autofunction:: mean_filter
+.. autofunction:: median_filter
+.. autofunction:: gaussian_filter
+.. autofunction:: maximum_filter
+.. autofunction:: minimum_filter
+.. autofunction:: dilation_filter
+.. autofunction:: erosion_filter
+.. autofunction:: log_filter
+.. autofunction:: remove_background_mean
+.. autofunction:: remove_background_gaussian
+
+------------
+
+Project images in 2D
+====================
+
+Build a 2D projection by computing the maximum, mean or median values:
+
+* :func:`bigfish.stack.maximum_projection`
+* :func:`bigfish.stack.mean_projection`
+* :func:`bigfish.stack.median_projection`
+
+.. autofunction:: maximum_projection
+.. autofunction:: mean_projection
+.. autofunction:: median_projection
+
+------------
+
+Clean out-of-focus pixels
+=========================
+
+Compute a pixel-wise focus score:
+
+* :func:`bigfish.stack.compute_focus`
+
+Remove the out-of-focus z-slices of a 3D image:
+
+* :func:`bigfish.stack.in_focus_selection`
+* :func:`bigfish.stack.get_in_focus_indices`
+
+Build a 2D projection by removing the out-of-focus z-slices/pixels:
+
+* :func:`bigfish.stack.focus_projection`
+
+.. autofunction:: bigfish.stack.compute_focus
+.. autofunction:: in_focus_selection
+.. autofunction:: get_in_focus_indices
+.. autofunction:: focus_projection
diff --git a/docs/source/utils/utils.rst b/docs/source/utils/utils.rst
new file mode 100644
index 00000000..ca6924be
--- /dev/null
+++ b/docs/source/utils/utils.rst
@@ -0,0 +1,82 @@
+.. _utils overview:
+
+Utility functions
+*****************
+
+.. currentmodule:: bigfish.stack
+
+Check input quality
+===================
+
+* :func:`bigfish.stack.check_array`
+* :func:`bigfish.stack.check_df`
+* :func:`bigfish.stack.check_parameter`
+* :func:`bigfish.stack.check_range_value`
+
+.. autofunction:: check_array
+.. autofunction:: check_df
+.. autofunction:: check_parameter
+.. autofunction:: check_range_value
+
+------------
+
+Get constant values
+===================
+
+* :func:`bigfish.stack.get_margin_value`
+* :func:`bigfish.stack.get_eps_float32`
+
+.. autofunction:: get_margin_value
+.. autofunction:: get_eps_float32
+
+------------
+
+Load and check stored data
+==========================
+
+* :func:`bigfish.stack.load_and_save_url`
+* :func:`bigfish.stack.check_hash`
+* :func:`bigfish.stack.compute_hash`
+* :func:`bigfish.stack.check_input_data`
+
+.. autofunction:: load_and_save_url
+.. autofunction:: check_hash
+.. autofunction:: compute_hash
+.. autofunction:: check_input_data
+
+------------
+
+Compute moving average
+======================
+
+* :func:`bigfish.stack.moving_average`
+* :func:`bigfish.stack.centered_moving_average`
+
+.. autofunction:: moving_average
+.. autofunction:: centered_moving_average
+
+------------
+
+Compute spot scale
+==================
+
+* :func:`bigfish.stack.get_sigma`
+* :func:`bigfish.stack.get_radius`
+
+.. autofunction:: get_sigma
+.. autofunction:: get_radius
+
+------------
+
+Format and save plots
+=====================
+
+.. currentmodule:: bigfish.plot
+
+* :func:`bigfish.plot.save_plot`
+* :func:`bigfish.plot.get_minmax_values`
+* :func:`bigfish.plot.create_colormap`
+
+.. autofunction:: save_plot
+.. autofunction:: get_minmax_values
+.. autofunction:: create_colormap
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 9a0138d3..79fca121 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,8 +1,10 @@
 absl-py==0.7.0
+alabaster==0.7.12
 appnope==0.1.0
 astor==0.7.1
 astunparse==1.6.3
 attrs==19.3.0
+Babel==2.9.0
 backcall==0.1.0
 bleach==3.1.5
 cachetools==4.1.0
@@ -22,11 +24,13 @@ google-pasta==0.2.0
 grpcio==1.29.0
 h5py==2.10.0
 idna==2.9
+imagesize==1.2.0
 importlib-metadata==1.4.0
 ipykernel==5.1.0
 ipython==7.2.0
 ipython-genutils==0.2.0
 jedi==0.13.2
+Jinja2==2.11.2
 joblib==0.13.2
 jupyter-client==5.2.4
 jupyter-core==4.4.0
@@ -34,14 +38,16 @@ Keras-Applications==1.0.8
 Keras-Preprocessing==1.1.2
 keyring==21.2.1
 kiwisolver==1.0.1
-llvmlite==0.29.0
+llvmlite==0.35.0
 Markdown==3.0.1
+MarkupSafe==1.1.1
 matplotlib==3.0.2
 more-itertools==8.1.0
 mrc==0.1.5
 networkx==2.2
-numba==0.37.0
+numba==0.52.0
 numpy==1.16.0
+numpydoc==1.1.0
 oauthlib==3.1.0
 opt-einsum==3.2.1
 packaging==20.0
@@ -51,7 +57,6 @@ pep517==0.8.2
 pexpect==4.6.0
 pickleshare==0.7.5
 Pillow==5.4.1
-pip==20.1.1
 pkginfo==1.5.0.1
 pluggy==0.13.1
 prompt-toolkit==2.0.7
@@ -60,6 +65,7 @@ ptyprocess==0.6.0
 py==1.8.1
 pyasn1==0.4.8
 pyasn1-modules==0.2.8
+pydot==1.4.1
 Pygments==2.6.1
 pyparsing==2.3.1
 pytest==5.3.3
@@ -76,12 +82,21 @@ rsa==4.6
 scikit-image==0.14.2
 scikit-learn==0.20.2
 scipy==1.4.1
-setuptools==47.3.1
 six==1.12.0
-tensorboard==2.2.2
+snowballstemmer==2.0.0
+Sphinx==3.3.1
+sphinx-rtd-theme==0.5.0
+sphinxcontrib-applehelp==1.0.2
+sphinxcontrib-devhelp==1.0.2
+sphinxcontrib-htmlhelp==1.0.3
+sphinxcontrib-jsmath==1.0.1
+sphinxcontrib-qthelp==1.0.3
+sphinxcontrib-serializinghtml==1.1.4
+tensorboard==2.4.0
 tensorboard-plugin-wit==1.7.0
-tensorflow==2.2.0
-tensorflow-estimator==2.2.0
+tensorflow==2.3.0
+tensorflow-addons==0.12.1
+tensorflow-estimator==2.3.0
 termcolor==1.1.0
 toml==0.10.1
 toolz==0.9.0
@@ -89,11 +104,11 @@ tornado==5.1.1
 tqdm==4.46.0
 traitlets==4.3.2
 twine==3.1.1
+typeguard==2.12.0
 umap-learn==0.3.9
 urllib3==1.25.9
 wcwidth==0.1.7
 webencodings==0.5.1
 Werkzeug==0.14.1
-wheel==0.32.3
 wrapt==1.12.1
 zipp==1.0.0
\ No newline at end of file
diff --git a/requirements_docs.txt b/requirements_docs.txt
new file mode 100644
index 00000000..4485b096
--- /dev/null
+++ b/requirements_docs.txt
@@ -0,0 +1,3 @@
+sphinx == 3.3.1
+numpydoc == 1.1.0
+sphinx-rtd-theme == 0.5.0
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 5b236fdd..14502f36 100644
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,8 @@
 with open("requirements.txt", encoding='utf-8') as f:
     REQUIREMENTS = [l.strip() for l in f.readlines() if l]
 DEEPLEARNING_REQUIREMENTS = [
-    'tensorflow == 2.2.0']
+    'tensorflow == 2.3.0',
+    'tensorflow-addons == 0.12.1']
 
 # long description of the package
 with open("README.md", "r") as f:
@@ -33,7 +34,7 @@
 # A list of classifiers to categorize the project (only used for searching and
 # browsing projects on PyPI)
 CLASSIFIERS = [
-    'Development Status :: 4 - Beta',
+    'Development Status :: 5 - Production/Stable',
     'Intended Audience :: Science/Research',
     'Intended Audience :: Developers',
     'Topic :: Software Development',
diff --git a/weights/.gitignore b/weights/.gitignore
new file mode 100644
index 00000000..86d0cb27
--- /dev/null
+++ b/weights/.gitignore
@@ -0,0 +1,4 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore
\ No newline at end of file


Test patch:
diff --git a/bigfish/detection/tests/test_foci_detection.py b/bigfish/detection/tests/test_cluster_detection.py
similarity index 50%
rename from bigfish/detection/tests/test_foci_detection.py
rename to bigfish/detection/tests/test_cluster_detection.py
index 61714196..80353ec2 100644
--- a/bigfish/detection/tests/test_foci_detection.py
+++ b/bigfish/detection/tests/test_cluster_detection.py
@@ -3,7 +3,7 @@
 # License: BSD 3 clause
 
 """
-Unitary tests for bigfish.stack.foci_detection module.
+Unitary tests for bigfish.detection.cluster_detection module.
 """
 
-# TODO test bigfish.detection.detect_foci
+# TODO test bigfish.detection.detect_clusters
diff --git a/bigfish/detection/tests/test_dense_decomposition.py b/bigfish/detection/tests/test_dense_decomposition.py
new file mode 100644
index 00000000..fac0acce
--- /dev/null
+++ b/bigfish/detection/tests/test_dense_decomposition.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.detection.dense_decomposition module.
+"""
+
+# TODO test bigfish.detection.decompose_dense
+# TODO test bigfish.detection.get_dense_region
+# TODO test bigfish.detection.simulate_gaussian_mixture
diff --git a/bigfish/detection/tests/test_spot_detection.py b/bigfish/detection/tests/test_spot_detection.py
index bd55c8ab..e5cb2314 100644
--- a/bigfish/detection/tests/test_spot_detection.py
+++ b/bigfish/detection/tests/test_spot_detection.py
@@ -3,9 +3,10 @@
 # License: BSD 3 clause
 
 """
-Unitary tests for bigfish.stack.spot_detection module.
+Unitary tests for bigfish.detection.spot_detection module.
 """
 
-# TODO test bigfish.detection.spot_detector
+# TODO test bigfish.detection.detect_spots
 # TODO test bigfish.detection.local_maximum_detection
 # TODO test bigfish.detection.spots_thresholding
+# TODO test bigfish.detection.automated_threshold_setting
diff --git a/bigfish/detection/tests/test_cluster_decomposition.py b/bigfish/detection/tests/test_spot_modeling.py
similarity index 54%
rename from bigfish/detection/tests/test_cluster_decomposition.py
rename to bigfish/detection/tests/test_spot_modeling.py
index 0ce88d8a..95b1e2a2 100644
--- a/bigfish/detection/tests/test_cluster_decomposition.py
+++ b/bigfish/detection/tests/test_spot_modeling.py
@@ -3,12 +3,10 @@
 # License: BSD 3 clause
 
 """
-Unitary tests for bigfish.stack.cluster_decomposition module.
+Unitary tests for bigfish.detection.spot_modeling module.
 """
 
-# TODO test bigfish.detection.decompose_cluster
+# TODO test bigfish.detection.fit_subpixel
 # TODO test bigfish.detection.build_reference_spot
 # TODO test bigfish.detection.modelize_spot
 # TODO test bigfish.detection.precompute_erf
-# TODO test bigfish.detection.get_clustered_region
-# TODO test bigfish.detection.fit_gaussian_mixture
diff --git a/bigfish/detection/tests/test_utils.py b/bigfish/detection/tests/test_utils.py
deleted file mode 100644
index cca5a1aa..00000000
--- a/bigfish/detection/tests/test_utils.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# -*- coding: utf-8 -*-
-# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
-# License: BSD 3 clause
-
-"""
-Unitary tests for bigfish.stack.utils module.
-"""
-
-# TODO test bigfish.utils.get_sigma
-# TODO test bigfish.utils.get_radius
diff --git a/bigfish/stack/tests/test_filter.py b/bigfish/stack/tests/test_filter.py
index a57ad1cc..f61a4fbc 100644
--- a/bigfish/stack/tests/test_filter.py
+++ b/bigfish/stack/tests/test_filter.py
@@ -93,9 +93,9 @@ def test_mean_filter():
     expected_x = np.array(
         [[2, 1, 0, 0, 0],
          [1, 1, 0, 0, 0],
-         [1, 0, 1, 0, 0],
          [0, 0, 1, 0, 0],
-         [0, 0, 1, 1, 1]],
+         [0, 0, 1, 0, 0],
+         [0, 0, 0, 0, 0]],
         dtype=np.uint8)
     assert_array_equal(filtered_x, expected_x)
     assert filtered_x.dtype == np.uint8
@@ -108,6 +108,28 @@ def test_mean_filter():
     assert_array_equal(filtered_x, expected_x)
     assert filtered_x.dtype == np.uint16
 
+    # np.float32
+    filtered_x = stack.mean_filter(x.astype(np.float32),
+                                   kernel_shape="square",
+                                   kernel_size=3)
+    expected_x = np.array(
+        [[2.333, 1.444, 0.556, 0., 0.],
+         [1.556, 1., 0.444, 0., 0.],
+         [0.889, 0.778, 1.111, 0.667, 0.556],
+         [0.333, 0.444, 1., 0.667, 0.556],
+         [0.222, 0.333, 0.889, 0.667, 0.556]],
+        dtype=np.float32)
+    assert_allclose(filtered_x, expected_x, rtol=1e-02)
+    assert filtered_x.dtype == np.float32
+
+    # np.float64
+    filtered_x = stack.mean_filter(x.astype(np.float64),
+                                   kernel_shape="square",
+                                   kernel_size=3)
+    expected_x = expected_x.astype(np.float64)
+    assert_allclose(filtered_x, expected_x, rtol=1e-02)
+    assert filtered_x.dtype == np.float64
+
 
 def test_median_filter():
     # np.uint8
diff --git a/bigfish/stack/tests/test_postprocess.py b/bigfish/stack/tests/test_postprocess.py
index ac64c37c..2937141d 100644
--- a/bigfish/stack/tests/test_postprocess.py
+++ b/bigfish/stack/tests/test_postprocess.py
@@ -27,14 +27,16 @@
 @pytest.mark.parametrize("ndim", [2, 3])
 @pytest.mark.parametrize("mask_dtype", [
     np.uint8, np.uint16, np.int64, bool])
-def test_identify_objects_in_region(ndim, mask_dtype):
+@pytest.mark.parametrize("spot_dtype", [
+    np.int64, np.float64])
+def test_identify_objects_in_region(ndim, mask_dtype, spot_dtype):
     # simulate mask and coordinates
     mask = np.zeros((10, 10), dtype=mask_dtype)
     mask[1:4, 1:5] = np.ones((3, 4), dtype=mask_dtype)
     spots_in = [[2, 1, 1], [5, 2, 2], [2, 3, 1], [9, 3, 4]]
-    spots_in = np.array(spots_in, dtype=np.int64)
+    spots_in = np.array(spots_in, dtype=spot_dtype)
     spots_out = [[1, 0, 0], [3, 7, 2], [2, 1, 8], [7, 8, 8]]
-    spots_out = np.array(spots_out, dtype=np.int64)
+    spots_out = np.array(spots_out, dtype=spot_dtype)
     if ndim == 2:
         spots_in = spots_in[:, 1:]
         spots_out = spots_out[:, 1:]
@@ -51,22 +53,24 @@ def test_identify_objects_in_region(ndim, mask_dtype):
 @pytest.mark.parametrize("ndim", [2, 3])
 @pytest.mark.parametrize("mask_dtype", [
     np.uint8, np.uint16, np.int64, bool])
-def test_remove_transcription_site(ndim, mask_dtype):
+@pytest.mark.parametrize("spot_dtype", [
+    np.int64, np.float64])
+def test_remove_transcription_site(ndim, mask_dtype, spot_dtype):
     # simulate mask and coordinates
     nuc_mask = np.zeros((10, 10), dtype=mask_dtype)
     nuc_mask[1:4, 1:5] = np.ones((3, 4), dtype=mask_dtype)
 
     spots_in_nuc_out_ts = [[2, 1, 1, -1], [5, 2, 2, -1], [2, 3, 1, -1]]
-    spots_in_nuc_out_ts = np.array(spots_in_nuc_out_ts, dtype=np.int64)
+    spots_in_nuc_out_ts = np.array(spots_in_nuc_out_ts, dtype=spot_dtype)
 
     spots_in_nuc_in_ts = [[2, 3, 3, 3], [5, 2, 4, 2], [2, 1, 3, 3]]
-    spots_in_nuc_in_ts = np.array(spots_in_nuc_in_ts, dtype=np.int64)
+    spots_in_nuc_in_ts = np.array(spots_in_nuc_in_ts, dtype=spot_dtype)
 
     spots_out_nuc_out_foci = [[1, 0, 0, -1], [3, 7, 2, -1], [2, 1, 8, -1]]
-    spots_out_nuc_out_foci = np.array(spots_out_nuc_out_foci, dtype=np.int64)
+    spots_out_nuc_out_foci = np.array(spots_out_nuc_out_foci, dtype=spot_dtype)
 
     spots_out_nuc_in_foci = [[1, 0, 4, 0], [3, 7, 7, 0], [2, 1, 5, 1]]
-    spots_out_nuc_in_foci = np.array(spots_out_nuc_in_foci, dtype=np.int64)
+    spots_out_nuc_in_foci = np.array(spots_out_nuc_in_foci, dtype=spot_dtype)
 
     if ndim == 2:
         spots_in_nuc_out_ts = spots_in_nuc_out_ts[:, 1:]
@@ -80,19 +84,19 @@ def test_remove_transcription_site(ndim, mask_dtype):
                                    spots_out_nuc_in_foci))
 
     ts = [[2, 2, 4, 1, 2], [4, 2, 3, 2, 3]]
-    ts = np.array(ts, dtype=np.int64)
+    ts = np.array(ts, dtype=spot_dtype)
 
     foci = [[0, 6, 7, 2, 0], [1, 2, 5, 1, 1]]
-    foci = np.array(foci, dtype=np.int64)
+    foci = np.array(foci, dtype=spot_dtype)
 
     if ndim == 2:
         ts = ts[:, 1:]
         foci = foci[:, 1:]
-    all_foci = np.concatenate((ts, foci))
+    all_clusters = np.concatenate((ts, foci))
 
     # test
     spots_out_ts_, foci_, ts_ = stack.remove_transcription_site(
-        spots, all_foci, nuc_mask, ndim)
+        spots, all_clusters, nuc_mask, ndim)
     assert_array_equal(spots_out_ts_, spots_out_ts)
     assert spots_out_ts_.dtype == spots_out_ts.dtype
     assert_array_equal(foci_, foci)
diff --git a/bigfish/stack/tests/test_projection.py b/bigfish/stack/tests/test_projection.py
index 1a8bb444..2173d16b 100644
--- a/bigfish/stack/tests/test_projection.py
+++ b/bigfish/stack/tests/test_projection.py
@@ -182,7 +182,5 @@ def test_one_hot_3d(dtype):
 
 # TODO remove test bigfish.stack.test_one_hot_3d
 # TODO test bigfish.stack.focus_projection
-# TODO test bigfish.stack.focus_projection_fast
 # TODO test bigfish.stack.in_focus_selection
-# TODO test bigfish.stack.focus_measurement
 # TODO test bigfish.stack.get_in_focus_indices