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

Issue:
Multistack subpackage
Create a new subpackage to gather functions interacting with different channels and process (detection, segmentation, GFP, FISH, dapi, etc...)


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 61ac261c..6c11c61f 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Big-FISH requires Python 3.6 or newer. Additionally, it has the following depend
 
 - numpy (== 1.16.0)
 - scipy (== 1.4.1)
-- scikit-learn (== 0.20.2)
+- scikit-learn (== 0.21.0)
 - scikit-image (== 0.14.2)
 - matplotlib (== 3.0.2)
 - pandas (== 0.24.0)
diff --git a/bigfish/__init__.py b/bigfish/__init__.py
index 814ab7c4..164c8de0 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.5.0"
+__version__ = "0.6.0"
diff --git a/bigfish/classification/features.py b/bigfish/classification/features.py
index 6e8c3846..53ed0993 100644
--- a/bigfish/classification/features.py
+++ b/bigfish/classification/features.py
@@ -17,6 +17,8 @@
 from .input_preparation import prepare_extracted_data
 
 
+# TODO allow RNA coordinates in float64 and int64
+
 # ### Main functions ###
 
 def compute_features(cell_mask, nuc_mask, ndim, rna_coord, smfish=None,
@@ -359,10 +361,14 @@ def features_distance(rna_coord, distance_cell, distance_nuc, cell_mask, ndim,
         if ndim not in [2, 3]:
             raise ValueError("'ndim' should be 2 or 3, not {0}.".format(ndim))
         stack.check_array(rna_coord, ndim=2, dtype=np.int64)
-        stack.check_array(distance_cell, ndim=2,
-                          dtype=[np.float16, np.float32, np.float64])
-        stack.check_array(distance_nuc, ndim=2,
-                          dtype=[np.float16, np.float32, np.float64])
+        stack.check_array(
+            distance_cell,
+            ndim=2,
+            dtype=[np.float16, np.float32, np.float64])
+        stack.check_array(
+            distance_nuc,
+            ndim=2,
+            dtype=[np.float16, np.float32, np.float64])
         stack.check_array(cell_mask, ndim=2, dtype=bool)
 
     # case where no mRNAs are detected
@@ -371,8 +377,9 @@ def features_distance(rna_coord, distance_cell, distance_nuc, cell_mask, ndim,
         return features
 
     # compute mean and median distance to cell membrane
-    rna_distance_cell = distance_cell[rna_coord[:, ndim - 2],
-                                      rna_coord[:, ndim - 1]]
+    rna_distance_cell = distance_cell[
+        rna_coord[:, ndim - 2],
+        rna_coord[:, ndim - 1]]
     expected_distance = np.mean(distance_cell[cell_mask])
     index_mean_dist_cell = np.mean(rna_distance_cell) / expected_distance
     expected_distance = np.median(distance_cell[cell_mask])
@@ -381,8 +388,9 @@ def features_distance(rna_coord, distance_cell, distance_nuc, cell_mask, ndim,
     features = (index_mean_dist_cell, index_median_dist_cell)
 
     # compute mean and median distance to nucleus
-    rna_distance_nuc = distance_nuc[rna_coord[:, ndim - 2],
-                                    rna_coord[:, ndim - 1]]
+    rna_distance_nuc = distance_nuc[
+        rna_coord[:, ndim - 2],
+        rna_coord[:, ndim - 1]]
     expected_distance = np.mean(distance_nuc[cell_mask])
     index_mean_dist_nuc = np.mean(rna_distance_nuc) / expected_distance
     expected_distance = np.median(distance_nuc[cell_mask])
@@ -481,8 +489,9 @@ def features_protrusion(rna_coord, cell_mask, nuc_mask, ndim, voxel_size_yx,
     # check parameters
     stack.check_parameter(check_input=bool)
     if check_input:
-        stack.check_parameter(ndim=int,
-                              voxel_size_yx=(int, float))
+        stack.check_parameter(
+            ndim=int,
+            voxel_size_yx=(int, float))
         if ndim not in [2, 3]:
             raise ValueError("'ndim' should be 2 or 3, not {0}.".format(ndim))
         stack.check_array(rna_coord, ndim=2, dtype=np.int64)
@@ -507,16 +516,18 @@ def features_protrusion(rna_coord, cell_mask, nuc_mask, ndim, voxel_size_yx,
 
     if protrusion_area > 0:
         expected_rna_protrusion = nb_rna * protrusion_area / cell_area
-        mask_rna = mask_cell_opened[rna_coord[:, ndim - 2],
-                                    rna_coord[:, ndim - 1]]
+        mask_rna = mask_cell_opened[
+            rna_coord[:, ndim - 2],
+            rna_coord[:, ndim - 1]]
         rna_after_opening = rna_coord[mask_rna]
         nb_rna_protrusion = nb_rna - len(rna_after_opening)
         index_rna_protrusion = nb_rna_protrusion / expected_rna_protrusion
         proportion_rna_protrusion = nb_rna_protrusion / nb_rna
 
-        features = (index_rna_protrusion,
-                    proportion_rna_protrusion,
-                    protrusion_area)
+        features = (
+            index_rna_protrusion,
+            proportion_rna_protrusion,
+            protrusion_area)
     else:
         features = (1., 0., 0.)
 
@@ -701,8 +712,9 @@ def features_topography(rna_coord, cell_mask, nuc_mask, cell_mask_out_nuc,
     # check parameters
     stack.check_parameter(check_input=bool)
     if check_input:
-        stack.check_parameter(ndim=int,
-                              voxel_size_yx=(int, float))
+        stack.check_parameter(
+            ndim=int,
+            voxel_size_yx=(int, float))
         if ndim not in [2, 3]:
             raise ValueError("'ndim' should be 2 or 3, not {0}.".format(ndim))
         stack.check_array(rna_coord, ndim=2, dtype=np.int64)
@@ -926,14 +938,17 @@ def features_centrosome(smfish, rna_coord, distance_centrosome, cell_mask,
     # check parameters
     stack.check_parameter(check_input=bool)
     if check_input:
-        stack.check_parameter(ndim=int,
-                              voxel_size_yx=(int, float))
+        stack.check_parameter(
+            ndim=int,
+            voxel_size_yx=(int, float))
         if ndim not in [2, 3]:
             raise ValueError("'ndim' should be 2 or 3, not {0}.".format(ndim))
         stack.check_array(smfish, ndim=2, dtype=[np.uint8, np.uint16])
         stack.check_array(rna_coord, ndim=2, dtype=np.int64)
-        stack.check_array(distance_centrosome, ndim=2,
-                          dtype=[np.float16, np.float32, np.float64])
+        stack.check_array(
+            distance_centrosome,
+            ndim=2,
+            dtype=[np.float16, np.float32, np.float64])
         stack.check_array(cell_mask, ndim=2, dtype=bool)
 
     # case where no mRNAs are detected
@@ -946,8 +961,9 @@ def features_centrosome(smfish, rna_coord, distance_centrosome, cell_mask,
     cell_area = cell_mask.sum()
 
     # compute mean and median distances from the centrosomes
-    rna_distance_cent = distance_centrosome[rna_coord[:, ndim - 2],
-                                            rna_coord[:, ndim - 1]]
+    rna_distance_cent = distance_centrosome[
+        rna_coord[:, ndim - 2],
+        rna_coord[:, ndim - 1]]
     expected_distance = np.mean(distance_centrosome[cell_mask])
     index_mean_dist_cent = np.mean(rna_distance_cent) / expected_distance
     expected_distance = np.median(distance_centrosome[cell_mask])
@@ -969,8 +985,9 @@ def features_centrosome(smfish, rna_coord, distance_centrosome, cell_mask,
         mask_centrosome[~cell_mask] = False
         centrosome_area = max(mask_centrosome.sum(), 1)
         expected_nb_rna = nb_rna * centrosome_area / cell_area
-        mask_rna = mask_centrosome[rna_coord[:, ndim - 2],
-                                   rna_coord[:, ndim - 1]]
+        mask_rna = mask_centrosome[
+            rna_coord[:, ndim - 2],
+            rna_coord[:, ndim - 1]]
         nb_rna_centrosome = len(rna_coord[mask_rna])
         index_rna_centrosome = nb_rna_centrosome / expected_nb_rna
         proportion_rna_centrosome = nb_rna_centrosome / len(rna_coord)
@@ -988,8 +1005,9 @@ def features_centrosome(smfish, rna_coord, distance_centrosome, cell_mask,
     total_intensity_cell = cell_value.sum()
 
     # compute attraction index
-    r = distance_centrosome[rna_coord[:, ndim - 2],
-                            rna_coord[:, ndim - 1]] ** 2
+    r = distance_centrosome[
+            rna_coord[:, ndim - 2],
+            rna_coord[:, ndim - 1]] ** 2
     a = np.sum((r * rna_value) / total_intensity_rna)
     r = distance_centrosome[cell_coord[:, 0], cell_coord[:, 1]] ** 2
     b = np.sum((r * cell_value) / total_intensity_cell)
diff --git a/bigfish/classification/input_preparation.py b/bigfish/classification/input_preparation.py
index a99def10..514b5b99 100644
--- a/bigfish/classification/input_preparation.py
+++ b/bigfish/classification/input_preparation.py
@@ -81,18 +81,23 @@ def prepare_extracted_data(cell_mask, nuc_mask=None, ndim=None, rna_coord=None,
         Distance map from the centrosome with shape (y, x), in pixels.
 
     """
+    # TODO allow RNA coordinates in float64 and int64
     # check parameters
     stack.check_parameter(ndim=(int, type(None)))
     if rna_coord is not None and ndim is None:
         raise ValueError("'ndim' should be specified (2 or 3).")
 
     # check arrays and make masks binary
-    stack.check_array(cell_mask, ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_array(
+        cell_mask,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, bool])
     cell_mask = cell_mask.astype(bool)
     if nuc_mask is not None:
-        stack.check_array(nuc_mask, ndim=2,
-                          dtype=[np.uint8, np.uint16, np.int64, bool])
+        stack.check_array(
+            nuc_mask,
+            ndim=2,
+            dtype=[np.uint8, np.uint16, np.int64, bool])
         nuc_mask = nuc_mask.astype(bool)
     if rna_coord is not None:
         stack.check_array(rna_coord, ndim=2, dtype=np.int64)
@@ -106,8 +111,9 @@ def prepare_extracted_data(cell_mask, nuc_mask=None, ndim=None, rna_coord=None,
 
     # get cell centroid and a distance map from its localisation
     centroid_cell = _get_centroid_surface(cell_mask)
-    distance_centroid_cell = _get_centroid_distance_map(centroid_cell,
-                                                        cell_mask)
+    distance_centroid_cell = _get_centroid_distance_map(
+        centroid_cell,
+        cell_mask)
 
     # prepare arrays relative to the nucleus
     if nuc_mask is not None:
@@ -124,8 +130,9 @@ def prepare_extracted_data(cell_mask, nuc_mask=None, ndim=None, rna_coord=None,
 
         # get nucleus centroid and a distance map from its localisation
         centroid_nuc = _get_centroid_surface(nuc_mask)
-        distance_centroid_nuc = _get_centroid_distance_map(centroid_nuc,
-                                                           cell_mask)
+        distance_centroid_nuc = _get_centroid_distance_map(
+            centroid_nuc,
+            cell_mask)
 
     else:
         cell_mask_out_nuc = None
@@ -159,12 +166,14 @@ def prepare_extracted_data(cell_mask, nuc_mask=None, ndim=None, rna_coord=None,
             if len(rna_coord_out_nuc) == 0:
                 centroid_rna_out_nuc = np.array([0] * ndim, dtype=np.int64)
             else:
-                centroid_rna_out_nuc = _get_centroid_rna(rna_coord_out_nuc,
-                                                         ndim)
+                centroid_rna_out_nuc = _get_centroid_rna(
+                    rna_coord_out_nuc,
+                    ndim)
 
             # build rna distance map (outside nucleus)
             distance_centroid_rna_out_nuc = _get_centroid_distance_map(
-                centroid_rna_out_nuc, cell_mask)
+                centroid_rna_out_nuc,
+                cell_mask)
 
         else:
             rna_coord_out_nuc = None
@@ -186,22 +195,31 @@ def prepare_extracted_data(cell_mask, nuc_mask=None, ndim=None, rna_coord=None,
             distance_centrosome = distance_cell.copy()
         else:
             distance_centrosome = _get_centrosome_distance_map(
-                centrosome_coord, cell_mask)
+                centrosome_coord,
+                cell_mask)
 
     else:
         distance_centrosome = None
 
     # gather cell, nucleus, rna and centrosome data
-    prepared_inputs = (cell_mask,
-                       distance_cell, distance_cell_normalized,
-                       centroid_cell, distance_centroid_cell,
-                       nuc_mask, cell_mask_out_nuc,
-                       distance_nuc, distance_nuc_normalized,
-                       centroid_nuc, distance_centroid_nuc,
-                       rna_coord_out_nuc,
-                       centroid_rna, distance_centroid_rna,
-                       centroid_rna_out_nuc, distance_centroid_rna_out_nuc,
-                       distance_centrosome)
+    prepared_inputs = (
+        cell_mask,
+        distance_cell,
+        distance_cell_normalized,
+        centroid_cell,
+        distance_centroid_cell,
+        nuc_mask,
+        cell_mask_out_nuc,
+        distance_nuc,
+        distance_nuc_normalized,
+        centroid_nuc,
+        distance_centroid_nuc,
+        rna_coord_out_nuc,
+        centroid_rna,
+        distance_centroid_rna,
+        centroid_rna_out_nuc,
+        distance_centroid_rna_out_nuc,
+        distance_centrosome)
 
     return prepared_inputs
 
diff --git a/bigfish/deep_learning/models_segmentation.py b/bigfish/deep_learning/models_segmentation.py
index 710a6c5e..508ef4cd 100644
--- a/bigfish/deep_learning/models_segmentation.py
+++ b/bigfish/deep_learning/models_segmentation.py
@@ -8,20 +8,21 @@
 
 import os
 import warnings
+
 from zipfile import ZipFile
 
+import tensorflow as tf
+
 import bigfish.stack as stack
 
-import tensorflow as tf
+from .utils_models import EncoderDecoder
+from .utils_models import SameConv
 
 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 ###
 
@@ -44,8 +45,9 @@ def load_pretrained_model(channel, model_name):
     # TODO fix warning partial restoration with distance model
 
     # check parameters
-    stack.check_parameter(channel=str,
-                          model_name=str)
+    stack.check_parameter(
+        channel=str,
+        model_name=str)
 
     # unet 3-classes for nucleus segmentation
     if model_name == "3_classes" and channel == "nuc":
@@ -87,8 +89,9 @@ def check_pretrained_weights(channel, model_name):
 
     """
     # check parameters
-    stack.check_parameter(channel=str,
-                          model_name=str)
+    stack.check_parameter(
+        channel=str,
+        model_name=str)
 
     # get path checkpoint
     path_weights_directory = _get_weights_directory()
@@ -182,18 +185,18 @@ def check_pretrained_weights(channel, model_name):
         # unzip
         path_zipfile = os.path.join(
             path_weights_directory, "{0}.zip".format(pretrained_directory))
-        with ZipFile(path_zipfile, 'r') as zip:
-            zip.extract(
+        with ZipFile(path_zipfile, 'r') as z:
+            z.extract(
                 member="{0}/checkpoint".format(pretrained_directory),
                 path=path_weights_directory)
-            zip.extract(
-                member="{0}/checkpoint.data-00000-of-00001"
-                    .format(pretrained_directory),
+            z.extract(
+                member="{0}/checkpoint.data-00000-of-00001".format(
+                    pretrained_directory),
                 path=path_weights_directory)
-            zip.extract(
+            z.extract(
                 member="{0}/checkpoint.index".format(pretrained_directory),
                 path=path_weights_directory)
-            zip.extract(
+            z.extract(
                 member="{0}/log".format(pretrained_directory),
                 path=path_weights_directory)
 
@@ -244,7 +247,9 @@ def build_compile_3_classes_model():
     """
     # define inputs
     inputs_image = Input(
-        shape=(None, None, 1), dtype="float32", name="image")
+        shape=(None, None, 1),
+        dtype="float32",
+        name="image")
 
     # define model
     outputs = _get_3_classes_model(inputs_image)
@@ -313,7 +318,9 @@ def build_compile_distance_model():
     """
     # define inputs
     inputs_image = Input(
-        shape=(None, None, 1), dtype="float32", name="image")
+        shape=(None, None, 1),
+        dtype="float32",
+        name="image")
 
     # define model
     output_surface, output_distance = _get_distance_model(inputs_image)
@@ -396,9 +403,13 @@ def build_compile_double_distance_model():
     """
     # define inputs
     inputs_nuc = Input(
-        shape=(None, None, 1), dtype="float32", name="nuc")
+        shape=(None, None, 1),
+        dtype="float32",
+        name="nuc")
     inputs_cell = Input(
-        shape=(None, None, 1), dtype="float32", name="cell")
+        shape=(None, None, 1),
+        dtype="float32",
+        name="cell")
     inputs = [inputs_nuc, inputs_cell]
 
     # define model
diff --git a/bigfish/detection/__init__.py b/bigfish/detection/__init__.py
index f627a709..d48d7d8e 100644
--- a/bigfish/detection/__init__.py
+++ b/bigfish/detection/__init__.py
@@ -17,17 +17,21 @@
 from .dense_decomposition import get_dense_region
 from .dense_decomposition import simulate_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 .spot_modeling import precompute_erf
+from .spot_modeling import fit_subpixel
 
 from .cluster_detection import detect_clusters
 
-from .snr import compute_snr_spots
+from .utils import convert_spot_coordinates
+from .utils import get_object_radius_pixel
+from .utils import get_object_radius_nm
+from .utils import build_reference_spot
+from .utils import compute_snr_spots
+from .utils import get_breaking_point
 
 
 _spots = [
@@ -43,18 +47,22 @@
     "simulate_gaussian_mixture"]
 
 _model = [
-    "fit_subpixel",
-    "build_reference_spot",
     "modelize_spot",
-    "precompute_erf",
     "initialize_grid",
     "gaussian_2d",
-    "gaussian_3d"]
+    "gaussian_3d",
+    "precompute_erf",
+    "fit_subpixel"]
 
 _clusters = [
     "detect_clusters"]
 
-_snr = [
-    "compute_snr_spots"]
+_utils = [
+    "convert_spot_coordinates",
+    "get_object_radius_pixel",
+    "get_object_radius_nm",
+    "build_reference_spot",
+    "compute_snr_spots",
+    "get_breaking_point"]
 
-__all__ = _spots + _dense + _model + _clusters + _snr
+__all__ = _spots + _dense + _model + _clusters + _utils
diff --git a/bigfish/detection/cluster_detection.py b/bigfish/detection/cluster_detection.py
index bdaba4c4..92e9487e 100644
--- a/bigfish/detection/cluster_detection.py
+++ b/bigfish/detection/cluster_detection.py
@@ -8,14 +8,17 @@
 """
 
 import numpy as np
+
 import bigfish.stack as stack
+
+from .utils import convert_spot_coordinates
+
 from sklearn.cluster import DBSCAN
 
 
 # ### Detect clusters ###
 
-def detect_clusters(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
-                    nb_min_spots=4):
+def detect_clusters(spots, voxel_size, radius=350, nb_min_spots=4):
     """Cluster spots and detect relevant aggregated structures.
 
     #. If two spots are distant within a specific radius, we consider they are
@@ -24,14 +27,13 @@ def detect_clusters(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
 
     Parameters
     ----------
-    spots : np.ndarray, np.int64
+    spots : np.ndarray
         Coordinates of the detected 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, spots are
-        considered in 2-d.
-    voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
     radius : int
         The maximum distance between two samples for one to be considered as
         in the neighborhood of the other. Radius expressed in nanometer.
@@ -42,44 +44,49 @@ def detect_clusters(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
 
     Returns
     -------
-    clustered_spots : np.ndarray, np.int64
+    clustered_spots : np.ndarray
         Coordinates of the detected spots with shape (nb_spots, 4) or
         (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.
-    clusters : np.ndarray, np.int64
+    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 spots detected in the clusters and its index.
 
     """
+    # TODO check that the behavior is the same with float64 and int64
+    #  coordinates
     # check parameters
-    stack.check_array(spots, ndim=2, dtype=np.int64)
-    stack.check_parameter(voxel_size_z=(int, float, type(None)),
-                          voxel_size_yx=(int, float),
-                          radius=int,
-                          nb_min_spots=int)
-
-    # check number of dimensions
+    stack.check_array(spots, ndim=2, dtype=[np.float64, np.int64])
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        radius=int,
+        nb_min_spots=int)
+
+    # check consistency between parameters
+    dtype = spots.dtype
     ndim = spots.shape[1]
     if ndim not in [2, 3]:
         raise ValueError("Spot coordinates should be in 2 or 3 dimensions, "
                          "not {0}.".format(ndim))
-    if ndim == 3 and voxel_size_z is None:
-        raise ValueError("Provided spot coordinates has {0} dimensions but "
-                         "'voxel_size_z' parameter is missing.".format(ndim))
-    if ndim == 2:
-        voxel_size_z = None
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
 
     # case where no spot were detected
     if spots.size == 0:
-        clustered_spots = np.array([], dtype=np.int64).reshape((0, ndim + 1))
-        clusters = np.array([], dtype=np.int64).reshape((0, ndim + 2))
+        clustered_spots = np.array([], dtype=dtype).reshape((0, ndim + 1))
+        clusters = np.array([], dtype=dtype).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)
+        spots, voxel_size, radius, nb_min_spots)
 
     # extract and shape clusters information
     clusters = _extract_information(clustered_spots)
@@ -87,50 +94,17 @@ def detect_clusters(spots, voxel_size_z=None, voxel_size_yx=100, radius=350,
     return clustered_spots, clusters
 
 
-def _convert_spot_coordinates(spots, voxel_size_z, voxel_size_yx):
-    """Convert spots coordinates from pixel to nanometer.
-
-    Parameters
-    ----------
-    spots : np.ndarray, np.int64
-        Coordinates of the detected spots with shape (nb_spots, 3) or
-        (nb_spots, 2).
-    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.
-
-    Returns
-    -------
-    spots_nanometer : np.ndarray, np.int64
-        Coordinates of the detected spots with shape (nb_spots, 3) or
-        (nb_spots, 3), in nanometer.
-
-    """
-    # convert spots coordinates in nanometer
-    spots_nanometer = spots.copy()
-    if spots.shape[1] == 3:
-        spots_nanometer[:, 0] *= voxel_size_z
-        spots_nanometer[:, 1:] *= voxel_size_yx
-
-    else:
-        spots_nanometer *= voxel_size_yx
-
-    return spots_nanometer
-
-
-def _cluster_spots(spots, voxel_size_z, voxel_size_yx, radius, nb_min_spots):
+def _cluster_spots(spots, voxel_size, radius, nb_min_spots):
     """Assign a cluster to each spot.
 
     Parameters
     ----------
-    spots : np.ndarray, np.int64
+    spots : np.ndarray
         Coordinates of the detected spots with shape (nb_spots, 3) or
         (nb_spots, 2).
-    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.
+    voxel_size : Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions).
     radius : int
         The maximum distance between two samples for one to be considered as
         in the neighborhood of the other. Radius expressed in nanometer.
@@ -141,7 +115,7 @@ def _cluster_spots(spots, voxel_size_z, voxel_size_yx, radius, nb_min_spots):
 
     Returns
     -------
-    clustered_spots : np.ndarray, np.int64
+    clustered_spots : np.ndarray
         Coordinates of the detected spots with shape (nb_spots, 4) or
         (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
@@ -149,9 +123,8 @@ def _cluster_spots(spots, voxel_size_z, voxel_size_yx, radius, nb_min_spots):
 
     """
     # convert spots coordinates in nanometer
-    spots_nanometer = _convert_spot_coordinates(spots=spots,
-                                                voxel_size_z=voxel_size_z,
-                                                voxel_size_yx=voxel_size_yx)
+    spots_nanometer = convert_spot_coordinates(
+        spots=spots, voxel_size=voxel_size)
 
     # fit a DBSCAN clustering algorithm with a specific radius
     dbscan = DBSCAN(eps=radius, min_samples=nb_min_spots)
@@ -171,7 +144,7 @@ def _extract_information(clustered_spots):
 
     Parameters
     ----------
-    clustered_spots : np.ndarray, np.int64
+    clustered_spots : np.ndarray
         Coordinates of the detected spots with shape (nb_spots, 4) or
         (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
@@ -179,7 +152,7 @@ def _extract_information(clustered_spots):
 
     Returns
     -------
-    clusters : np.ndarray, np.int64
+    clusters : np.ndarray
         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.
diff --git a/bigfish/detection/dense_decomposition.py b/bigfish/detection/dense_decomposition.py
index ad3da6d3..88054faf 100644
--- a/bigfish/detection/dense_decomposition.py
+++ b/bigfish/detection/dense_decomposition.py
@@ -12,9 +12,15 @@
 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 .utils import build_reference_spot
+from .utils import get_object_radius_pixel
+from .spot_modeling import modelize_spot
+from .spot_modeling import precompute_erf
+from .spot_modeling import gaussian_2d
+from .spot_modeling import _initialize_grid_2d
+from .spot_modeling import gaussian_3d
+from .spot_modeling import _initialize_grid_3d
 
 from skimage.measure import regionprops
 from skimage.measure import label
@@ -22,8 +28,8 @@
 
 # ### 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):
+def decompose_dense(image, spots, voxel_size, spot_radius, kernel_size=None,
+                    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.
 
@@ -41,17 +47,19 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
     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.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    spot_radius : int, float, Tuple(int, float) or List(int, float)
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions.
+    kernel_size : int, float, Tuple(float, int), List(float, int) or None
+        Standard deviation used for the gaussian kernel (one for each
+        dimension), in pixel. If it's a scalar, the same standard deviation is
+        applied to every dimensions. If None, we estimate the kernel size from
+        'spot_radius', 'voxel_size' and 'gamma'
     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
@@ -68,16 +76,20 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
         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:
+        Multiplicative factor use to compute the gaussian kernel size:
 
         .. math::
-            \\mbox{scale} = \\frac{\\gamma * \\mbox{PSF}}{\\mbox{voxel size}}
+            \\mbox{kernel size} = \\frac{\\gamma * \\mbox{spot radius}}{\\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.
+
+    Notes
+    -----
+    If ``gamma = 0`` and ``kernel_size = None``, image is not denoised.
 
     Returns
     -------
@@ -93,18 +105,20 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
         Reference spot in 3-d or 2-d.
 
     """
+    # TODO allow/return float64 spots
     # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    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))
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        spot_radius=(int, float, tuple, list),
+        kernel_size=(int, float, tuple, list, type(None)),
+        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))
@@ -115,20 +129,32 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
         raise ValueError("'gamma' should be a positive value, not {0}"
                          .format(gamma))
 
-    # check number of dimensions
+    # 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
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence "
+                "with {0} elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(spot_radius, (tuple, list)):
+        if len(spot_radius) != ndim:
+            raise ValueError("'spot_radius' must be a scalar or a "
+                             "sequence with {0} elements.".format(ndim))
+    else:
+        spot_radius = (spot_radius,) * ndim
+    if kernel_size is not None:
+        if isinstance(kernel_size, (tuple, list)):
+            if len(kernel_size) != ndim:
+                raise ValueError("'kernel_size' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            kernel_size = (kernel_size,) * ndim
 
     # case where no spot were detected
     if spots.size == 0:
@@ -136,24 +162,30 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
         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])
+    # get gaussian kernel to denoise the image
+    if kernel_size is None and gamma > 0:
+        spot_radius_px = get_object_radius_pixel(
+            voxel_size_nm=voxel_size,
+            object_radius_nm=spot_radius,
+            ndim=ndim)
+        kernel_size = tuple([spot_radius_px_ * gamma
+                             for spot_radius_px_ in spot_radius_px])
 
     # denoise the image
-    if gamma > 0:
+    if kernel_size is not None:
         image_denoised = stack.remove_background_gaussian(
-            image,
-            sigma=large_sigma)
+            image=image,
+            sigma=kernel_size)
     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)
+        image=image_denoised,
+        spots=spots,
+        voxel_size=voxel_size,
+        spot_radius=spot_radius,
+        alpha=alpha)
 
     # case with an empty frame as reference spot
     if reference_spot.sum() == 0:
@@ -162,19 +194,23 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
 
     # 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)
+        reference_spot=reference_spot,
+        voxel_size=voxel_size,
+        spot_radius=spot_radius)
     if ndim == 3:
         sigma_z, sigma_yx, amplitude, background = parameters_fitted
+        sigma = (sigma_z, sigma_yx, sigma_yx)
     else:
-        sigma_z = None
         sigma_yx, amplitude, background = parameters_fitted
+        sigma = (sigma_yx, sigma_yx)
 
     # 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)
+        image=image_denoised,
+        spots=spots,
+        voxel_size=voxel_size,
+        spot_radius=spot_radius,
+        beta=beta)
 
     # case where no region where detected
     if regions_to_decompose.size == 0:
@@ -184,16 +220,17 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
     # 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)
+        ndim=ndim,
+        voxel_size=voxel_size,
+        sigma=sigma,
+        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,
+        voxel_size=voxel_size,
+        sigma=sigma,
         amplitude=amplitude,
         background=background,
         precomputed_gaussian=precomputed_gaussian)
@@ -214,8 +251,7 @@ def decompose_dense(image, spots, voxel_size_z=None, voxel_size_yx=100,
 
 # ### Dense regions ###
 
-def get_dense_region(image, spots, voxel_size_z=None, voxel_size_yx=100,
-                     psf_z=None, psf_yx=200, beta=1):
+def get_dense_region(image, spots, voxel_size, spot_radius, beta=1):
     """Detect and filter dense and bright regions.
 
     A candidate region has at least 2 connected pixels above a specific
@@ -227,17 +263,14 @@ def get_dense_region(image, spots, voxel_size_z=None, voxel_size_yx=100,
         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.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    spot_radius : int, float, Tuple(int, float) or List(int, float)
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions.
     beta : int or float
         Multiplicative factor for the intensity threshold of a dense region.
         Default is 1. Threshold is computed with the formula:
@@ -261,40 +294,48 @@ def get_dense_region(image, spots, voxel_size_z=None, voxel_size_yx=100,
         Maximum size of the regions.
 
     """
+    # TODO allow/return float64 spots
     # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    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))
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        spot_radius=(int, float, tuple, list),
+        beta=(int, float))
     if beta < 0:
         raise ValueError("'beta' should be a positive value, not {0}"
                          .format(beta))
 
-    # check number of dimensions
+    # 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
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(spot_radius, (tuple, list)):
+        if len(spot_radius) != ndim:
+            raise ValueError(
+                "'spot_radius' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        spot_radius = (spot_radius,) * ndim
 
     # 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,
+        image=image,
+        spots=spots,
+        voxel_size=voxel_size,
+        spot_radius=spot_radius,
         alpha=0.5)
     threshold = int(median_spot.max() * beta)
 
@@ -460,8 +501,7 @@ def _filter_spot_out_candidate_regions(candidate_bbox, spots, nb_dim):
 
 # ### Gaussian simulation ###
 
-def simulate_gaussian_mixture(image, candidate_regions, voxel_size_z=None,
-                              voxel_size_yx=100, sigma_z=None, sigma_yx=200,
+def simulate_gaussian_mixture(image, candidate_regions, voxel_size, sigma,
                               amplitude=100, background=0,
                               precomputed_gaussian=None):
     """Simulate as many gaussians as possible in the candidate dense regions in
@@ -473,22 +513,20 @@ def simulate_gaussian_mixture(image, candidate_regions, voxel_size_z=None,
         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.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    sigma : int, float, Tuple(int, float) or List(int, float)
+        Standard deviation of the gaussian, in nanometer. One value per
+        spatial dimension (zyx or yx dimensions). If it's a scalar, the same
+        value is applied to every dimensions.
     amplitude : float
         Amplitude of the gaussian.
     background : float
-        Background minimum value of the image.
+        Background minimum value.
     precomputed_gaussian : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
+        Tuple with tables of precomputed values for the erf, with shape
         (nb_value, 2). One table per dimension.
 
     Returns
@@ -504,59 +542,65 @@ def simulate_gaussian_mixture(image, candidate_regions, voxel_size_z=None,
         average intensity value and its index.
 
     """
+    # TODO allow/return float64 spots
     # 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)
+    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=(int, float, tuple, list),
+        sigma=(int, float, tuple, list),
+        amplitude=float,
+        background=float)
     if background < 0:
         raise ValueError("Background value can't be negative: {0}"
                          .format(background))
 
-    # check number of dimensions
+    # 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 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
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(sigma, (tuple, list)):
+        if len(sigma) != ndim:
+            raise ValueError(
+                "'sigma' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        sigma = (sigma,) * ndim
 
     # simulate gaussian mixtures in the candidate regions...
     spots_in_regions = []
     regions = []
 
     # ... for 3-d regions...
-    if image.ndim == 3:
+    if 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)
+                image=image,
+                region=region,
+                voxel_size_z=voxel_size[0],
+                voxel_size_yx=voxel_size[-1],
+                sigma_z=sigma[0],
+                sigma_yx=sigma[-1],
+                amplitude=amplitude,
+                background=background,
+                precomputed_gaussian=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
+            coord[:, 0] = (coord[:, 0] / voxel_size[0]) + min_z
+            coord[:, 1] = (coord[:, 1] / voxel_size[-1]) + min_y
+            coord[:, 2] = (coord[:, 2] / voxel_size[-1]) + 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
@@ -573,20 +617,20 @@ def simulate_gaussian_mixture(image, candidate_regions, voxel_size_z=None,
 
         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)
+                image=image,
+                region=region,
+                voxel_size_yx=voxel_size[-1],
+                sigma_yx=sigma[-1],
+                amplitude=amplitude,
+                background=background,
+                precomputed_gaussian=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
+            coord[:, 0] = (coord[:, 0] / voxel_size[-1]) + min_y
+            coord[:, 1] = (coord[:, 1] / voxel_size[-1]) + 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
@@ -616,19 +660,19 @@ def _gaussian_mixture_3d(image, region, voxel_size_z, voxel_size_yx, sigma_z,
     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.
+        Size 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.
+        Size of a voxel in the yx plan, in nanometer.
     sigma_z : int or float
-        Standard deviation of the gaussian along the z axis, in pixel.
+        Standard deviation of the gaussian along the z axis, in nanometer.
     sigma_yx : int or float
-        Standard deviation of the gaussian along the yx axis, in pixel.
+        Standard deviation of the gaussian in the yx plan, in nanometer.
     amplitude : float
         Amplitude of the gaussian.
     background : float
-        Background minimum value of the image.
+        Background minimum value.
     precomputed_gaussian : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
+        Tuple with 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.
@@ -664,17 +708,18 @@ def _gaussian_mixture_3d(image, region, voxel_size_z, voxel_size_yx, sigma_z,
     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)
+        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,
+            amplitude=amplitude,
+            background=background,
+            precomputed=precomputed_gaussian)
         residual = image_region_raw - simulation
         new_ssr = np.sum(residual ** 2)
         diff_ssr = new_ssr - ssr
@@ -716,15 +761,15 @@ def _gaussian_mixture_2d(image, region, voxel_size_yx, sigma_yx, amplitude,
     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.
+        Size of a voxel in the yx plan, in nanometer.
     sigma_yx : int or float
-        Standard deviation of the gaussian along the yx axis, in pixel.
+        Standard deviation of the gaussian in the yx plan, in nanometer.
     amplitude : float
         Amplitude of the gaussian.
     background : float
-        Background minimum value of the image.
+        Background minimum value.
     precomputed_gaussian : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
+        Tuple with 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.
@@ -760,14 +805,15 @@ def _gaussian_mixture_2d(image, region, voxel_size_yx, sigma_yx, amplitude,
     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)
+        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,
+            amplitude=amplitude,
+            background=background,
+            precomputed=precomputed_gaussian)
         residual = image_region_raw - simulation
         new_ssr = np.sum(residual ** 2)
         diff_ssr = new_ssr - ssr
diff --git a/bigfish/detection/snr.py b/bigfish/detection/snr.py
deleted file mode 100644
index 7eb37c50..00000000
--- a/bigfish/detection/snr.py
+++ /dev/null
@@ -1,172 +0,0 @@
-# -*- 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 9ab4c395..0d6fd83f 100644
--- a/bigfish/detection/spot_detection.py
+++ b/bigfish/detection/spot_detection.py
@@ -7,11 +7,15 @@
 """
 
 import warnings
+
 import scipy.ndimage as ndi
 import numpy as np
 
 import bigfish.stack as stack
 
+from .utils import get_object_radius_pixel
+from .utils import get_breaking_point
+
 from skimage.measure import regionprops
 from skimage.measure import label
 
@@ -19,8 +23,8 @@
 # ### Main function ###
 
 def detect_spots(images, threshold=None, remove_duplicate=True,
-                 return_threshold=False, voxel_size_z=None, voxel_size_yx=100,
-                 psf_z=None, psf_yx=200):
+                 return_threshold=False, voxel_size=None, spot_radius=None,
+                 log_kernel_size=None, minimum_distance=None):
     """Apply LoG filter followed by a Local Maximum algorithm to detect spots
     in a 2-d or 3-d image.
 
@@ -36,7 +40,7 @@ def detect_spots(images, threshold=None, remove_duplicate=True,
     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.
-    threshold : float or int
+    threshold : int, float or None
         A threshold to discriminate relevant spots from noisy blobs. If None,
         optimal threshold is selected automatically. If several images are
         provided, one optimal threshold is selected for all the images.
@@ -45,17 +49,28 @@ def detect_spots(images, threshold=None, remove_duplicate=True,
         running.
     return_threshold : bool
         Return the threshold used to detect spots.
-    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.
+    voxel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions. Not used if 'log_kernel_size' and 'minimum_distance' are
+        provided.
+    spot_radius : int, float, Tuple(int, float), List(int, float) or None
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions. Not used if 'log_kernel_size' and 'minimum_distance'
+        are provided.
+    log_kernel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of the LoG kernel. It equals the standard deviation (in pixels)
+        used for the gaussian kernel (one for each dimension). One value per
+        spatial dimension (zyx or yx dimensions). If it's a scalar, the same
+        standard deviation is applied to every dimensions. If None, we estimate
+        it with the voxel size and spot radius.
+    minimum_distance : int, float, Tuple(int, float), List(int, float) or None
+        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. If None, we estimate it with the voxel size and spot
+        radius.
 
     Returns
     -------
@@ -67,30 +82,31 @@ def detect_spots(images, threshold=None, remove_duplicate=True,
 
     """
     # check parameters
-    stack.check_parameter(threshold=(float, int, type(None)),
-                          remove_duplicate=bool,
-                          return_threshold=bool,
-                          voxel_size_z=(int, float, type(None)),
-                          voxel_size_yx=(int, float),
-                          psf_z=(int, float, type(None)),
-                          psf_yx=(int, float))
+    stack.check_parameter(
+        threshold=(int, float, type(None)),
+        remove_duplicate=bool,
+        return_threshold=bool,
+        voxel_size=(int, float, tuple, list, type(None)),
+        spot_radius=(int, float, tuple, list, type(None)),
+        log_kernel_size=(int, float, tuple, list, type(None)),
+        minimum_distance=(int, float, tuple, list, type(None)))
 
     # 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])
+        stack.check_array(
+            images,
+            ndim=[2, 3],
+            dtype=[np.uint8, np.uint16, np.float32, np.float64])
         ndim = images.ndim
         images = [images]
         is_list = False
     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])
+            stack.check_array(
+                image,
+                ndim=[2, 3],
+                dtype=[np.uint8, np.uint16, np.float32, np.float64])
             if i == 0:
                 ndim = image.ndim
             else:
@@ -99,16 +115,70 @@ def detect_spots(images, threshold=None, remove_duplicate=True,
                                      "number of dimensions.")
         is_list = True
 
-    # 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
+    # check consistency between parameters - detection with voxel size and
+    # spot radius
+    if (voxel_size is not None and spot_radius is not None
+            and log_kernel_size is None and minimum_distance is None):
+        if isinstance(voxel_size, (tuple, list)):
+            if len(voxel_size) != ndim:
+                raise ValueError("'voxel_size' must be a scalar or a sequence "
+                                 "with {0} elements.".format(ndim))
+        else:
+            voxel_size = (voxel_size,) * ndim
+        if isinstance(spot_radius, (tuple, list)):
+            if len(spot_radius) != ndim:
+                raise ValueError("'spot_radius' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            spot_radius = (spot_radius,) * ndim
+        log_kernel_size = get_object_radius_pixel(
+            voxel_size_nm=voxel_size,
+            object_radius_nm=spot_radius,
+            ndim=ndim)
+        minimum_distance = get_object_radius_pixel(
+            voxel_size_nm=voxel_size,
+            object_radius_nm=spot_radius,
+            ndim=ndim)
+
+    # check consistency between parameters - detection with kernel size and
+    # minimal distance
+    elif (voxel_size is None and spot_radius is None
+          and log_kernel_size is not None and minimum_distance is not None):
+        if isinstance(log_kernel_size, (tuple, list)):
+            if len(log_kernel_size) != ndim:
+                raise ValueError("'log_kernel_size' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            log_kernel_size = (log_kernel_size,) * ndim
+        if isinstance(minimum_distance, (tuple, list)):
+            if len(minimum_distance) != ndim:
+                raise ValueError("'minimum_distance' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            minimum_distance = (minimum_distance,) * ndim
+
+    # check consistency between parameters - detection in priority with kernel
+    # size and minimal distance
+    elif (voxel_size is not None and spot_radius is not None
+          and log_kernel_size is not None and minimum_distance is not None):
+        if isinstance(log_kernel_size, (tuple, list)):
+            if len(log_kernel_size) != ndim:
+                raise ValueError("'log_kernel_size' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            log_kernel_size = (log_kernel_size,) * ndim
+        if isinstance(minimum_distance, (tuple, list)):
+            if len(minimum_distance) != ndim:
+                raise ValueError("'minimum_distance' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            minimum_distance = (minimum_distance,) * ndim
+
+    # missing parameters
+    else:
+        raise ValueError("One of the two pairs of parameters ('voxel_size', "
+                         "'spot_radius') or ('log_kernel_size', "
+                         "'minimum_distance') should be provided.")
 
     # detect spots
     if return_threshold:
@@ -117,20 +187,16 @@ def detect_spots(images, threshold=None, remove_duplicate=True,
             threshold=threshold,
             remove_duplicate=remove_duplicate,
             return_threshold=return_threshold,
-            voxel_size_z=voxel_size_z,
-            voxel_size_yx=voxel_size_yx,
-            psf_z=psf_z,
-            psf_yx=psf_yx)
+            log_kernel_size=log_kernel_size,
+            min_distance=minimum_distance)
     else:
         spots = _detect_spots_from_images(
             images,
             threshold=threshold,
             remove_duplicate=remove_duplicate,
             return_threshold=return_threshold,
-            voxel_size_z=voxel_size_z,
-            voxel_size_yx=voxel_size_yx,
-            psf_z=psf_z,
-            psf_yx=psf_yx)
+            log_kernel_size=log_kernel_size,
+            min_distance=minimum_distance)
 
     # format results
     if not is_list:
@@ -144,8 +210,8 @@ def detect_spots(images, threshold=None, remove_duplicate=True,
 
 
 def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
-                              return_threshold=False, voxel_size_z=None,
-                              voxel_size_yx=100, psf_z=None, psf_yx=200):
+                              return_threshold=False, log_kernel_size=None,
+                              min_distance=None):
     """Apply LoG filter followed by a Local Maximum algorithm to detect spots
     in a 2-d or 3-d image.
 
@@ -170,17 +236,18 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
         running.
     return_threshold : bool
         Return the threshold used to detect spots.
-    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.
+    log_kernel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of the LoG kernel. It equals the standard deviation (in pixels)
+        used for the gaussian kernel (one for each dimension). One value per
+        spatial dimension (zyx or yx dimensions). If it's a scalar, the same
+        standard deviation is applied to every dimensions. If None, we estimate
+        it with the voxel size and spot radius.
+    min_distance : int, float, Tuple(int, float), List(int, float) or None
+        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. If None, we estimate it with the voxel size and spot
+        radius.
 
     Returns
     -------
@@ -192,7 +259,6 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
 
     """
     # initialization
-    sigma = stack.get_sigma(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
     n = len(images)
 
     # apply LoG filter and find local maximum
@@ -201,14 +267,14 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
     masks = []
     for image in images:
         # filter image
-        image_filtered = stack.log_filter(image, sigma)
+        image_filtered = stack.log_filter(image, log_kernel_size)
         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)
+        mask_local_max = local_maximum_detection(image_filtered, min_distance)
         masks.append(mask_local_max)
 
     # get optimal threshold if necessary based on all the images
@@ -234,7 +300,7 @@ def _detect_spots_from_images(images, threshold=None, remove_duplicate=True,
 
         # select threshold where the kink of the distribution is located
         if count_spots.size > 0:
-            threshold, _, _ = _get_breaking_point(thresholds, count_spots)
+            threshold, _, _ = get_breaking_point(thresholds, count_spots)
 
     # detect spots
     all_spots = []
@@ -275,7 +341,7 @@ def local_maximum_detection(image, min_distance):
     ----------
     image : np.ndarray
         Image to process with shape (z, y, x) or (y, x).
-    min_distance : int, float or Tuple(float)
+    min_distance : int, float, Tuple(int, float), List(int, 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
@@ -288,22 +354,23 @@ def local_maximum_detection(image, min_distance):
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    stack.check_parameter(min_distance=(float, int, tuple))
+    stack.check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_parameter(min_distance=(int, float, tuple, list))
 
     # compute the kernel size (centered around our pixel because it is uneven)
-    if isinstance(min_distance, (int, float)):
-        min_distance = (min_distance,) * image.ndim
-        min_distance = np.ceil(min_distance).astype(image.dtype)
-    elif image.ndim != len(min_distance):
-        raise ValueError("'min_distance' should be a scalar or a tuple with "
-                         "one value per dimension. Here the image has {0} "
-                         "dimensions and 'min_distance' {1} elements."
-                         .format(image.ndim, len(min_distance)))
+    if isinstance(min_distance, (tuple, list)):
+        if len(min_distance) != image.ndim:
+            raise ValueError(
+                "'min_distance' should be a scalar or a sequence with one "
+                "value per dimension. Here the image has {0} dimensions and "
+                "'min_distance' {1} elements.".format(image.ndim,
+                                                      len(min_distance)))
     else:
-        min_distance = np.ceil(min_distance).astype(image.dtype)
+        min_distance = (min_distance,) * image.ndim
+    min_distance = np.ceil(min_distance).astype(image.dtype)
     kernel_size = 2 * min_distance + 1
 
     # apply maximum filter to the original image
@@ -320,10 +387,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 (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.
+    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
     ----------
@@ -348,14 +415,17 @@ def spots_thresholding(image, mask_local_max, threshold,
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    stack.check_array(mask_local_max,
-                      ndim=[2, 3],
-                      dtype=[bool])
-    stack.check_parameter(threshold=(float, int, type(None)),
-                          remove_duplicate=bool)
+    stack.check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_array(
+        mask_local_max,
+        ndim=[2, 3],
+        dtype=[bool])
+    stack.check_parameter(
+        threshold=(float, int, type(None)),
+        remove_duplicate=bool)
 
     if threshold is None:
         mask = np.zeros_like(image, dtype=bool)
@@ -425,12 +495,11 @@ def automated_threshold_setting(image, mask_local_max):
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    stack.check_array(mask_local_max,
-                      ndim=[2, 3],
-                      dtype=[bool])
+    stack.check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_array(mask_local_max, ndim=[2, 3], dtype=[bool])
 
     # get threshold values we want to test
     thresholds = _get_candidate_thresholds(image.ravel())
@@ -444,7 +513,7 @@ def automated_threshold_setting(image, mask_local_max):
 
     # select threshold where the break of the distribution is located
     if count_spots.size > 0:
-        optimal_threshold, _, _ = _get_breaking_point(thresholds, count_spots)
+        optimal_threshold, _, _ = get_breaking_point(thresholds, count_spots)
 
     # case where no spots were detected
     else:
@@ -509,100 +578,72 @@ def _get_spot_counts(thresholds, value_spots):
     return thresholds, count_spots
 
 
-def _get_breaking_point(x, y):
-    """Select the x-axis value where a L-curve has a kink.
-
-    Assuming a L-curve from A to B, the 'breaking_point' is the more distant
-    point to the segment [A, B].
-
-    Parameters
-    ----------
-    x : np.array, np.float64
-        X-axis values.
-    y : np.array, np.float64
-        Y-axis values.
-
-    Returns
-    -------
-    breaking_point : float
-        X-axis value at the kink location.
-    x : np.array, np.float64
-        X-axis values.
-    y : np.array, np.float64
-        Y-axis values.
-
-    """
-    # select threshold where curve break
-    slope = (y[-1] - y[0]) / len(y)
-    y_grad = np.gradient(y)
-    m = list(y_grad >= slope)
-    j = m.index(False)
-    m = m[j:]
-    x = x[j:]
-    y = y[j:]
-    if True in m:
-        i = m.index(True)
-    else:
-        i = -1
-    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):
+def get_elbow_values(images, voxel_size=None, spot_radius=None,
+                     log_kernel_size=None, minimum_distance=None):
     """Get values to plot the elbow curve used to automatically set the
-    threshold.
+    threshold to detect spots.
 
     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.
+    voxel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions. Not used if 'log_kernel_size' and 'minimum_distance' are
+        provided.
+    spot_radius : int, float, Tuple(int, float), List(int, float) or None
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions. Not used if 'log_kernel_size' and 'minimum_distance'
+        are provided.
+    log_kernel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of the LoG kernel. It equals the standard deviation (in pixels)
+        used for the gaussian kernel (one for each dimension). One value per
+        spatial dimension (zyx or yx dimensions). If it's a scalar, the same
+        standard deviation is applied to every dimensions. If None, we estimate
+        it with the voxel size and spot radius.
+    minimum_distance : int, float, Tuple(int, float), List(int, float) or None
+        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. If None, we estimate it with the voxel size and spot
+        radius.
 
     Returns
     -------
     thresholds : np.ndarray, np.float64
         Candidate threshold values.
     count_spots : np.ndarray, np.float64
-        Spots count function.
+        Spots count.
     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))
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list, type(None)),
+        spot_radius=(int, float, tuple, list, type(None)),
+        log_kernel_size=(int, float, tuple, list, type(None)),
+        minimum_distance=(int, float, tuple, list, type(None)))
 
     # 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])
+        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])
+            stack.check_array(
+                image,
+                ndim=[2, 3],
+                dtype=[np.uint8, np.uint16, np.float32, np.float64])
             if i == 0:
                 ndim = image.ndim
             else:
@@ -611,19 +652,75 @@ def get_elbow_values(images, voxel_size_z=None, voxel_size_yx=100, psf_z=None,
                                      "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)
+    # check consistency between parameters - detection with voxel size and
+    # spot radius
+    if (voxel_size is not None and spot_radius is not None
+            and log_kernel_size is None and minimum_distance is None):
+        if isinstance(voxel_size, (tuple, list)):
+            if len(voxel_size) != ndim:
+                raise ValueError(
+                    "'voxel_size' must be a scalar or a sequence "
+                    "with {0} elements.".format(ndim))
+        else:
+            voxel_size = (voxel_size,) * ndim
+        if isinstance(spot_radius, (tuple, list)):
+            if len(spot_radius) != ndim:
+                raise ValueError("'spot_radius' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            spot_radius = (spot_radius,) * ndim
+
+        log_kernel_size = get_object_radius_pixel(
+            voxel_size_nm=voxel_size,
+            object_radius_nm=spot_radius,
+            ndim=ndim)
+        minimum_distance = get_object_radius_pixel(
+            voxel_size_nm=voxel_size,
+            object_radius_nm=spot_radius,
+            ndim=ndim)
+
+    # check consistency between parameters - detection with kernel size and
+    # minimal distance
+    elif (voxel_size is None and spot_radius is None
+          and log_kernel_size is not None and minimum_distance is not None):
+        if isinstance(log_kernel_size, (tuple, list)):
+            if len(log_kernel_size) != ndim:
+                raise ValueError("'log_kernel_size' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            log_kernel_size = (log_kernel_size,) * ndim
+        if isinstance(minimum_distance, (tuple, list)):
+            if len(minimum_distance) != ndim:
+                raise ValueError(
+                    "'minimum_distance' must be a scalar or a "
+                    "sequence with {0} elements.".format(ndim))
+        else:
+            minimum_distance = (minimum_distance,) * ndim
+
+    # check consistency between parameters - detection in priority with kernel
+    # size and minimal distance
+    elif (voxel_size is not None and spot_radius is not None
+          and log_kernel_size is not None and minimum_distance is not None):
+        if isinstance(log_kernel_size, (tuple, list)):
+            if len(log_kernel_size) != ndim:
+                raise ValueError("'log_kernel_size' must be a scalar or a "
+                                 "sequence with {0} elements.".format(ndim))
+        else:
+            log_kernel_size = (log_kernel_size,) * ndim
+        if isinstance(minimum_distance, (tuple, list)):
+            if len(minimum_distance) != ndim:
+                raise ValueError(
+                    "'minimum_distance' must be a scalar or a "
+                    "sequence with {0} elements.".format(ndim))
+        else:
+            minimum_distance = (minimum_distance,) * ndim
+
+    # missing parameters
+    else:
+        raise ValueError(
+            "One of the two pairs of parameters ('voxel_size', "
+            "'spot_radius') or ('log_kernel_size', "
+            "'minimum_distance') should be provided.")
 
     # apply LoG filter and find local maximum
     images_filtered = []
@@ -631,7 +728,7 @@ def get_elbow_values(images, voxel_size_z=None, voxel_size_yx=100, psf_z=None,
     masks = []
     for image in images:
         # filter image
-        image_filtered = stack.log_filter(image, sigma)
+        image_filtered = stack.log_filter(image, log_kernel_size)
         images_filtered.append(image_filtered)
 
         # get pixels value
@@ -639,7 +736,7 @@ def get_elbow_values(images, voxel_size_z=None, voxel_size_yx=100, psf_z=None,
 
         # find local maximum
         mask_local_max = local_maximum_detection(
-            image_filtered, sigma)
+            image_filtered, minimum_distance)
         masks.append(mask_local_max)
 
     # get threshold values we want to test
@@ -663,7 +760,7 @@ def get_elbow_values(images, voxel_size_z=None, voxel_size_yx=100, psf_z=None,
 
     # select threshold where the kink of the distribution is located
     if count_spots.size > 0:
-        threshold, _, _ = _get_breaking_point(thresholds, count_spots)
+        threshold, _, _ = get_breaking_point(thresholds, count_spots)
     else:
         threshold = None
 
diff --git a/bigfish/detection/spot_modeling.py b/bigfish/detection/spot_modeling.py
index 542c6cf2..43ef9c4a 100644
--- a/bigfish/detection/spot_modeling.py
+++ b/bigfish/detection/spot_modeling.py
@@ -6,380 +6,89 @@
 Functions to model spots by fitting gaussian parameters.
 """
 
-import warnings
-
 import numpy as np
 
 import bigfish.stack as stack
 
+from .utils import _get_spot_volume
+from .utils import _get_spot_surface
+from .utils import get_object_radius_pixel
+
 from scipy.special import erf
 from scipy.optimize import curve_fit
 
 
-# ### Reference spot ###
-
-def build_reference_spot(image, spots, voxel_size_z=None, voxel_size_yx=100,
-                         psf_z=None, psf_yx=200, alpha=0.5):
-    """Build a median or mean spot in 3 or 2 dimensions as reference.
-
-    Reference spot is computed from a sample of uncropped detected spots. If
-    such sample is not possible, an empty frame is returned.
-
-    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) for 3-d images or
-        (nb_spots, 2) for 2-d 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.
-    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
-        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))
-    if alpha < 0 or alpha > 1:
-        raise ValueError("'alpha' should be a value between 0 and 1, not {0}"
-                         .format(alpha))
-
-    # 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
-
-    # compute radius
-    radius = stack.get_radius(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
-
-    # build reference spot
-    if image.ndim == 3:
-        reference_spot = _build_reference_spot_3d(image, spots, radius, alpha)
-    else:
-        reference_spot = _build_reference_spot_2d(image, spots, radius, alpha)
-
-    return reference_spot
-
-
-def _build_reference_spot_3d(image, spots, radius, alpha):
-    """Build a median or mean spot in 3 dimensions as reference.
-
-    Reference spot is computed from a sample of uncropped detected spots. If
-    such sample is not possible, an empty frame is returned.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image with shape (z, y, x).
-    spots : np.ndarray, np.int64
-        Coordinate of the spots with shape (nb_spots, 3) for 3-d images.
-    radius : Tuple[float]
-        Radius in pixels of the detected spots, one element per dimension.
-    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.
-
-    Returns
-    -------
-    reference_spot : np.ndarray
-        Reference spot in 3-d.
-
-    """
-    # get a rounded radius for each dimension
-    radius_z = np.ceil(radius[0]).astype(np.int64)
-    z_shape = radius_z * 2 + 1
-    radius_yx = np.ceil(radius[-1]).astype(np.int64)
-    yx_shape = radius_yx * 2 + 1
-
-    # randomly choose some spots to aggregate
-    indices = [i for i in range(spots.shape[0])]
-    np.random.shuffle(indices)
-    indices = indices[:min(2000, spots.shape[0])]
-    candidate_spots = spots[indices, :]
-
-    # collect area around each spot
-    l_reference_spot = []
-    for i_spot in range(candidate_spots.shape[0]):
-
-        # get spot coordinates
-        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)
-
-        # keep images that are not cropped by the borders
-        if image_spot.shape == (z_shape, yx_shape, yx_shape):
-            l_reference_spot.append(image_spot)
-
-    # if not enough spots are detected
-    if len(l_reference_spot) <= 30:
-        warnings.warn("Problem occurs during the computation of a reference "
-                      "spot. Not enough (uncropped) spots have been detected.",
-                      UserWarning)
-    if len(l_reference_spot) == 0:
-        reference_spot = np.zeros((z_shape, yx_shape, yx_shape),
-                                  dtype=image.dtype)
-        return reference_spot
-
-    # project the different spot images
-    l_reference_spot = np.stack(l_reference_spot, axis=0)
-    alpha_ = alpha * 100
-    reference_spot = np.percentile(l_reference_spot, alpha_, axis=0)
-    reference_spot = reference_spot.astype(image.dtype)
-
-    return reference_spot
-
-
-def _get_spot_volume(image, spot_z, spot_y, spot_x, radius_z, radius_yx):
-    """Get a subimage of a detected spot in 3 dimensions.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image with shape (z, y, x).
-    spot_z : np.int64
-        Coordinate of the detected spot along the z axis.
-    spot_y : np.int64
-        Coordinate of the detected spot along the y axis.
-    spot_x : np.int64
-        Coordinate of the detected spot along the x axis.
-    radius_z : int
-        Radius in pixels of the detected spot, along the z axis.
-    radius_yx : int
-        Radius in pixels of the detected spot, on the yx plan.
-
-    Returns
-    -------
-    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
-    z_spot_min = max(0, int(spot_z - radius_z))
-    z_spot_max = min(image.shape[0], int(spot_z + radius_z))
-    y_spot_min = max(0, int(spot_y - radius_yx))
-    y_spot_max = min(image.shape[1], int(spot_y + radius_yx))
-    x_spot_min = max(0, int(spot_x - radius_yx))
-    x_spot_max = min(image.shape[2], int(spot_x + radius_yx))
-
-    # get the volume of the spot
-    image_spot = image[z_spot_min:z_spot_max + 1,
-                       y_spot_min:y_spot_max + 1,
-                       x_spot_min:x_spot_max + 1]
-
-    return image_spot, (z_spot_min, y_spot_min, x_spot_min)
-
-
-def _build_reference_spot_2d(image, spots, radius, alpha):
-    """Build a median or mean spot in 2 dimensions as reference.
-
-    Reference spot is computed from a sample of uncropped detected spots. If
-    such sample is not possible, an empty frame is returned.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image with shape (y, x).
-    spots : np.ndarray, np.int64
-        Coordinate of the spots with shape (nb_spots, 2) for 2-d images.
-    radius : Tuple[float]
-        Radius in pixels of the detected spots, one element per dimension.
-    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.
-
-    Returns
-    -------
-    reference_spot : np.ndarray
-        Reference spot in 2-d.
-
-    """
-    # get a rounded radius for each dimension
-    radius_yx = np.ceil(radius[-1]).astype(np.int64)
-    yx_shape = radius_yx * 2 + 1
-
-    # randomly choose some spots to aggregate
-    indices = [i for i in range(spots.shape[0])]
-    np.random.shuffle(indices)
-    indices = indices[:min(2000, spots.shape[0])]
-    candidate_spots = spots[indices, :]
-
-    # collect area around each spot
-    l_reference_spot = []
-    for i_spot in range(candidate_spots.shape[0]):
-
-        # get spot coordinates
-        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)
-
-        # keep images that are not cropped by the borders
-        if image_spot.shape == (yx_shape, yx_shape):
-            l_reference_spot.append(image_spot)
-
-    # if not enough spots are detected
-    if len(l_reference_spot) <= 30:
-        warnings.warn("Problem occurs during the computation of a reference "
-                      "spot. Not enough (uncropped) spots have been detected.",
-                      UserWarning)
-    if len(l_reference_spot) == 0:
-        reference_spot = np.zeros((yx_shape, yx_shape), dtype=image.dtype)
-        return reference_spot
-
-    # project the different spot images
-    l_reference_spot = np.stack(l_reference_spot, axis=0)
-    alpha_ = alpha * 100
-    reference_spot = np.percentile(l_reference_spot, alpha_, axis=0)
-    reference_spot = reference_spot.astype(image.dtype)
-
-    return reference_spot
-
-
-def _get_spot_surface(image, spot_y, spot_x, radius_yx):
-    """Get a subimage of a detected spot in 2 dimensions.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image with shape (y, x).
-    spot_y : np.int64
-        Coordinate of the detected spot along the y axis.
-    spot_x : np.int64
-        Coordinate of the detected spot along the x axis.
-    radius_yx : int
-        Radius in pixels of the detected spot, on the yx plan.
-
-    Returns
-    -------
-    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
-    y_spot_min = max(0, int(spot_y - radius_yx))
-    y_spot_max = min(image.shape[0], int(spot_y + radius_yx))
-    x_spot_min = max(0, int(spot_x - radius_yx))
-    x_spot_max = min(image.shape[1], int(spot_x + radius_yx))
-
-    # get the surface of the spot
-    image_spot = image[y_spot_min:y_spot_max + 1,
-                       x_spot_min:x_spot_max + 1]
-
-    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, return_coord=False):
+def modelize_spot(reference_spot, voxel_size, spot_radius, return_coord=False):
     """Fit a gaussian function on the reference spot.
 
     Parameters
     ----------
     reference_spot : np.ndarray
         A 3-d or 2-d image with detected spot and shape (z, y, x) or (y, x).
-    voxel_size_z : int or float or None
-        Height of a voxel, along the z axis, in nanometer. If None, reference
-        spot 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, 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.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    spot_radius : int, float, Tuple(int, float) or List(int, float)
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions.
     return_coord : bool
-        Return spot coordinates.
+        Return gaussian coordinates.
 
     Returns
     -------
     parameters_fitted : Tuple[float]
         * mu_z : float (optional)
-            Coordinate of the spot center along the z axis, in pixel.
+            Coordinate of the gaussian center along the z axis, in pixel.
         * mu_y : float (optional)
-            Coordinate of the spot center along the y axis, in pixel.
+            Coordinate of the gaussian center along the y axis, in pixel.
         * mu_x : float (optional)
-            Coordinate of the spot center along the x axis, in pixel.
+            Coordinate of the gaussian center along the x axis, in pixel.
         * sigma_z : float
-            Standard deviation of the spot along the z axis, in pixel.
+            Standard deviation of the gaussian along the z axis, in pixel.
             Available only for a 3-d modelization.
         * sigma_yx : float
-            Standard deviation of the spot along the yx axis, in pixel.
+            Standard deviation of the gaussian in the yx plan, in pixel.
         * amplitude : float
-            Amplitude of the spot.
+            Amplitude of the gaussian.
         * background : float
             Background minimum value of the image.
 
     """
     # check parameters
-    stack.check_array(reference_spot,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    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),
-                          return_coord=bool)
-
-    # check number of dimensions
+    stack.check_array(
+        reference_spot,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        spot_radius=(int, float, tuple, list),
+        return_coord=bool)
+
+    # check consistency between parameters
     ndim = reference_spot.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 == 2:
-        voxel_size_z, psf_z = None, None
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(spot_radius, (tuple, list)):
+        if len(spot_radius) != ndim:
+            raise ValueError(
+                "'spot_radius' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        spot_radius = (spot_radius,) * ndim
 
     # initialize a grid representing the reference spot
     grid, centroid_coord = initialize_grid(
         image_spot=reference_spot,
-        voxel_size_z=voxel_size_z,
-        voxel_size_yx=voxel_size_yx,
+        voxel_size=voxel_size,
         return_centroid=True)
 
     # compute amplitude and background of the reference spot
@@ -387,32 +96,30 @@ def modelize_spot(reference_spot, voxel_size_z=None, voxel_size_yx=100,
 
     # initialize parameters of the gaussian function
     f = _objective_function(
-        nb_dimension=ndim,
-        voxel_size_z=voxel_size_z,
-        voxel_size_yx=voxel_size_yx,
-        psf_z=None,
-        psf_yx=None,
-        psf_amplitude=None)
+        ndim=ndim,
+        voxel_size=voxel_size,
+        sigma_z=None,
+        sigma_yx=None,
+        amplitude=None)
     if ndim == 3:
         # parameters to fit: mu_z, mu_y, mu_x, sigma_z, sigma_yx, amplitude
         # and background
         centroid_z, centroid_y, centroid_x = centroid_coord
-        p0 = [centroid_z, centroid_y, centroid_x, psf_z, psf_yx, amplitude,
-              background]
+        p0 = [centroid_z, centroid_y, centroid_x, spot_radius[0],
+              spot_radius[-1], amplitude, background]
         l_bound = [-np.inf, -np.inf, -np.inf, -np.inf, -np.inf, -np.inf, 0]
         u_bound = [np.inf, np.inf, np.inf, np.inf, np.inf, np.inf, np.inf]
 
     else:
         # parameters to fit: mu_y, mu_x, sigma_yx, amplitude and background
         centroid_y, centroid_x = centroid_coord
-        p0 = [centroid_y, centroid_x, psf_yx, amplitude, background]
+        p0 = [centroid_y, centroid_x, spot_radius[-1], amplitude, background]
         l_bound = [-np.inf, -np.inf, -np.inf, -np.inf, 0]
         u_bound = [np.inf, np.inf, np.inf, np.inf, np.inf]
 
     # fit a gaussian function on this reference spot
-    popt, pcov = _fit_gaussian(f, grid, reference_spot, p0,
-                               lower_bound=l_bound,
-                               upper_bound=u_bound)
+    popt, pcov = _fit_gaussian(
+        f, grid, reference_spot, p0, lower_bound=l_bound, upper_bound=u_bound)
 
     # get optimized parameters to modelize the reference spot as a gaussian
     if ndim == 3:
@@ -442,10 +149,7 @@ def modelize_spot(reference_spot, voxel_size_z=None, voxel_size_yx=100,
             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, return_centroid=False):
     """Build a grid in nanometer to compute gaussian function values over a
     full volume or surface.
 
@@ -453,11 +157,10 @@ def initialize_grid(image_spot, voxel_size_z, voxel_size_yx,
     ----------
     image_spot : np.ndarray
         An image with detected spot and shape (z, y, x) or (y, x).
-    voxel_size_z : int or float or None
-        Height of a voxel, along the z axis, in nanometer. If None, image spot
-        is considered in 2-d.
-    voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
     return_centroid : bool
         Compute centroid estimation of the grid.
 
@@ -470,26 +173,45 @@ def initialize_grid(image_spot, voxel_size_z, voxel_size_yx,
         dimension.
 
     """
+    # check parameters
+    stack.check_array(
+        image_spot,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        return_centroid=bool)
+
+    # check consistency between parameters
+    ndim = image_spot.ndim
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+
     # initialize grid in 2-d...
     if image_spot.ndim == 2:
         if return_centroid:
             grid, centroid_y, centroid_x = _initialize_grid_2d(
-                image_spot, voxel_size_yx, return_centroid)
+                image_spot, voxel_size[-1], return_centroid)
             return grid, (centroid_y, centroid_x)
         else:
             grid = _initialize_grid_2d(
-                image_spot, voxel_size_yx, return_centroid)
+                image_spot, voxel_size[-1], return_centroid)
             return grid
 
     # ... or 3-d
     else:
         if return_centroid:
             grid, centroid_z, centroid_y, centroid_x = _initialize_grid_3d(
-                image_spot, voxel_size_z, voxel_size_yx, return_centroid)
+                image_spot, voxel_size[0], voxel_size[-1], return_centroid)
             return grid, (centroid_z, centroid_y, centroid_x)
         else:
             grid = _initialize_grid_3d(
-                image_spot, voxel_size_z, voxel_size_yx, return_centroid)
+                image_spot, voxel_size[0], voxel_size[-1], return_centroid)
             return grid
 
 
@@ -503,9 +225,9 @@ def _initialize_grid_3d(image_spot, voxel_size_z, voxel_size_yx,
     image_spot : np.ndarray
         A 3-d image with detected spot and shape (z, y, x).
     voxel_size_z : int or float
-        Height of a voxel, along the z axis, in nanometer.
+        Size 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.
+        Size of a voxel in the yx plan, in nanometer.
     return_centroid : bool
         Compute centroid estimation of the grid.
 
@@ -562,7 +284,7 @@ def _initialize_grid_2d(image_spot, voxel_size_yx, return_centroid=False):
     image_spot : np.ndarray
         A 2-d image with detected spot and shape (y, x).
     voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
+        Size of a voxel in the yx plan, in nanometer.
     return_centroid : bool
         Compute centroid estimation of the grid.
 
@@ -571,9 +293,9 @@ def _initialize_grid_2d(image_spot, voxel_size_yx, return_centroid=False):
     grid : np.ndarray, np.float32
         A grid with the shape (2, y * x), in nanometer.
     centroid_y : float
-        Estimated centroid of the spot, in nanometer, along the y axis.
+        Estimated centroid of the spot along the y axis, in nanometer.
     centroid_x : float
-        Estimated centroid of the spot, in nanometer, along the x axis.
+        Estimated centroid of the spot along the x axis, in nanometer.
 
     """
     # get targeted size
@@ -613,43 +335,40 @@ def _initialize_background_amplitude(image_spot):
 
     Returns
     -------
-    psf_amplitude : float
+    amplitude : float
         Amplitude of the spot.
-    psf_background : float
+    background : float
         Background minimum value of the voxel.
 
     """
     # compute values
     image_min, image_max = image_spot.min(), image_spot.max()
-    psf_amplitude = image_max - image_min
-    psf_background = image_min
+    amplitude = image_max - image_min
+    background = image_min
 
-    return psf_amplitude, psf_background
+    return amplitude, background
 
 
-# ### Spot modelization: fitting ###
+# ### Pixel fitting ###
 
-def _objective_function(nb_dimension, voxel_size_z, voxel_size_yx, psf_z,
-                        psf_yx, psf_amplitude=None):
+def _objective_function(ndim, voxel_size, sigma_z, sigma_yx,
+                        amplitude):
     """Design the objective function used to fit the gaussian function.
 
     Parameters
     ----------
-    nb_dimension : int
+    ndim : int
         Number of dimensions to consider (2 or 3).
-    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 gaussian function.
-    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 gaussian function.
-    psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
-    psf_amplitude : int or float
-        Amplitude of the spot.
+    voxel_size : Tuple(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions).
+    sigma_z : int, float or None
+        Standard deviation of the gaussian along the z axis, in nanometer. If
+        None, we consider a 2-d gaussian function.
+    sigma_yx : int, float or None
+        Standard deviation of the gaussian in the yx plan, in nanometer.
+    amplitude : int, float or None
+        Amplitude of the gaussian.
 
     Returns
     -------
@@ -658,38 +377,38 @@ def _objective_function(nb_dimension, voxel_size_z, voxel_size_yx, psf_z,
 
     """
     # define objective gaussian function
-    if nb_dimension == 3:
-        f = _objective_function_3d(voxel_size_z=voxel_size_z,
-                                   voxel_size_yx=voxel_size_yx,
-                                   psf_z=psf_z,
-                                   psf_yx=psf_yx,
-                                   psf_amplitude=psf_amplitude)
+    if ndim == 3:
+        f = _objective_function_3d(
+            voxel_size_z=voxel_size[0],
+            voxel_size_yx=voxel_size[-1],
+            sigma_z=sigma_z,
+            sigma_yx=sigma_yx,
+            amplitude=amplitude)
     else:
-        f = _objective_function_2d(voxel_size_yx=voxel_size_yx,
-                                   psf_yx=psf_yx,
-                                   psf_amplitude=psf_amplitude)
+        f = _objective_function_2d(
+            voxel_size_yx=voxel_size[-1],
+            sigma_yx=sigma_yx,
+            amplitude=amplitude)
 
     return f
 
 
-def _objective_function_3d(voxel_size_z, voxel_size_yx, psf_z, psf_yx,
-                           psf_amplitude=None):
+def _objective_function_3d(voxel_size_z, voxel_size_yx, sigma_z, sigma_yx,
+                           amplitude):
     """Design the objective function used to fit the gaussian function.
 
     Parameters
     ----------
     voxel_size_z : int or float
-        Height of a voxel, along the z axis, in nanometer.
+        Size 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.
-    psf_z : int or float
-        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.
-    psf_amplitude : int or float
-        Amplitude of the spot.
+        Size of a voxel in the yx plan, in nanometer.
+    sigma_z : int, float or None
+        Standard deviation of the gaussian along the z axis, in nanometer.
+    sigma_yx : int, float or None
+        Standard deviation of the gaussian in the yx plan, in nanometer.
+    amplitude : int, float or None
+        Amplitude of the gaussian.
 
     Returns
     -------
@@ -698,84 +417,88 @@ def _objective_function_3d(voxel_size_z, voxel_size_yx, psf_z, psf_yx,
 
     """
     # sigma is known, we fit mu, amplitude and background
-    if (psf_z is not None
-            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)
+    if (sigma_z is not None
+            and sigma_yx is not None
+            and amplitude is None):
+        def f(grid, mu_z, mu_y, mu_x, amplitude, background):
+            values = gaussian_3d(
+                grid=grid,
+                mu_z=mu_z,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_z=sigma_z,
+                sigma_yx=sigma_yx,
+                voxel_size_z=voxel_size_z,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=background)
             return values
 
     # amplitude is known, we fit sigma, mu and background
-    elif (psf_amplitude is not None
-          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)
+    elif (amplitude is not None
+          and sigma_z is None
+          and sigma_yx is None):
+        def f(grid, mu_z, mu_y, mu_x, sigma_z, sigma_yx, background):
+            values = gaussian_3d(
+                grid=grid,
+                mu_z=mu_z,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_z=sigma_z,
+                sigma_yx=sigma_yx,
+                voxel_size_z=voxel_size_z,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=background)
             return values
 
     # amplitude and sigma are known, we fit mu and background
-    elif (psf_amplitude is not None
-          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)
+    elif (amplitude is not None
+          and sigma_z is not None
+          and sigma_yx is not None):
+        def f(grid, mu_z, mu_y, mu_x, background):
+            values = gaussian_3d(
+                grid=grid,
+                mu_z=mu_z,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_z=sigma_z,
+                sigma_yx=sigma_yx,
+                voxel_size_z=voxel_size_z,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=background)
             return values
 
     # we fit mu, sigma, amplitude and background
-    elif (psf_amplitude is None
-          and psf_z is None
-          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)
+    elif (amplitude is None
+          and sigma_z is None
+          and sigma_yx is None):
+        def f(grid, mu_z, mu_y, mu_x, sigma_z, sigma_yx, amplitude,
+              background):
+            values = gaussian_3d(
+                grid=grid,
+                mu_z=mu_z,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_z=sigma_z,
+                sigma_yx=sigma_yx,
+                voxel_size_z=voxel_size_z,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=background)
             return values
 
     else:
-        raise ValueError("Parameters 'psf_z' and 'psf_yx' should be "
-                         "fixed or optimized together.")
+        raise ValueError("Parameters 'sigma_z' and 'sigma_yx' should be set "
+                         "or optimized together.")
 
     return f
 
 
 # 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,
+                voxel_size_yx, amplitude, background,
                 precomputed=None):
     """Compute the gaussian function over the grid representing a volume V
     with shape (V_z, V_y, V_x).
@@ -794,17 +517,17 @@ def gaussian_3d(grid, mu_z, mu_y, mu_x, sigma_z, sigma_yx, voxel_size_z,
     sigma_z : int or float
         Standard deviation of the gaussian along the z axis, in nanometer.
     sigma_yx : int or float
-        Standard deviation of the gaussian along the yx axis, in nanometer.
+        Standard deviation of the gaussian in the yx plan, in nanometer.
     voxel_size_z : int or float
-        Height of a voxel, in nanometer.
+        Size of a voxel along the z axis, in nanometer.
     voxel_size_yx : int or float
-        size of a voxel, in nanometer.
-    psf_amplitude : float
-        Estimated pixel intensity of a spot.
-    psf_background : float
+        Size of a voxel in the yx plan, in nanometer.
+    amplitude : float
+        Estimated pixel intensity of the gaussian signal.
+    background : float
         Estimated pixel intensity of the background.
-    precomputed : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
+    precomputed : Tuple[np.ndarray] or None
+        Tuple with tables of precomputed values for the erf, with shape
         (nb_value, 2). One table per dimension.
 
     Returns
@@ -847,39 +570,41 @@ def gaussian_3d(grid, mu_z, mu_y, mu_x, sigma_z, sigma_yx, voxel_size_z,
         meshgrid_x_plus = meshgrid_x + voxel_size_yx / 2
 
         # compute gaussian function for each voxel (i, j, k) of volume V
-        voxel_integral_z = _rescaled_erf(low=meshgrid_z_minus,
-                                         high=meshgrid_z_plus,
-                                         mu=mu_z,
-                                         sigma=sigma_z)
-        voxel_integral_y = _rescaled_erf(low=meshgrid_y_minus,
-                                         high=meshgrid_y_plus,
-                                         mu=mu_y,
-                                         sigma=sigma_yx)
-        voxel_integral_x = _rescaled_erf(low=meshgrid_x_minus,
-                                         high=meshgrid_x_plus,
-                                         mu=mu_x,
-                                         sigma=sigma_yx)
+        voxel_integral_z = _rescaled_erf(
+            low=meshgrid_z_minus,
+            high=meshgrid_z_plus,
+            mu=mu_z,
+            sigma=sigma_z)
+        voxel_integral_y = _rescaled_erf(
+            low=meshgrid_y_minus,
+            high=meshgrid_y_plus,
+            mu=mu_y,
+            sigma=sigma_yx)
+        voxel_integral_x = _rescaled_erf(
+            low=meshgrid_x_minus,
+            high=meshgrid_x_plus,
+            mu=mu_x,
+            sigma=sigma_yx)
 
     # compute 3-d gaussian values
-    factor = psf_amplitude / (voxel_size_yx ** 2 * voxel_size_z)
+    factor = amplitude / (voxel_size_yx ** 2 * voxel_size_z)
     voxel_integral = voxel_integral_z * voxel_integral_y * voxel_integral_x
-    values = psf_background + factor * voxel_integral
+    values = background + factor * voxel_integral
 
     return values
 
 
-def _objective_function_2d(voxel_size_yx, psf_yx, psf_amplitude=None):
+def _objective_function_2d(voxel_size_yx, sigma_yx, amplitude):
     """Design the objective function used to fit a 2-d gaussian function.
 
     Parameters
     ----------
     voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
-    psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
-    psf_amplitude : float
-        Amplitude of the spot.
+        Size of a voxel in the yx plan, in nanometer.
+    sigma_yx : int, float or None
+        Standard deviation of the gaussian in the yx plan, in nanometer.
+    amplitude : int, float or None
+        Amplitude of the gaussian.
 
     Returns
     -------
@@ -888,59 +613,63 @@ 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)
+    if sigma_yx is not None and amplitude is None:
+        def f(grid, mu_y, mu_x, amplitude, background):
+            values = gaussian_2d(
+                grid=grid,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_yx=sigma_yx,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=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)
+    elif amplitude is not None and sigma_yx is None:
+        def f(grid, mu_y, mu_x, sigma_yx, background):
+            values = gaussian_2d(
+                grid=grid,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_yx=sigma_yx,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=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)
+    elif amplitude is not None and sigma_yx is not None:
+        def f(grid, mu_y, mu_x, background):
+            values = gaussian_2d(
+                grid=grid,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_yx=sigma_yx,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=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)
+        def f(grid, mu_y, mu_x, sigma_yx, amplitude, background):
+            values = gaussian_2d(
+                grid=grid,
+                mu_y=mu_y,
+                mu_x=mu_x,
+                sigma_yx=sigma_yx,
+                voxel_size_yx=voxel_size_yx,
+                amplitude=amplitude,
+                background=background)
             return values
 
     return f
 
 
 # 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):
+def gaussian_2d(grid, mu_y, mu_x, sigma_yx, voxel_size_yx, amplitude,
+                background, precomputed=None):
     """Compute the gaussian function over the grid representing a surface S
     with shape (S_y, S_x).
 
@@ -954,15 +683,15 @@ def gaussian_2d(grid, mu_y, mu_x, sigma_yx, voxel_size_yx, psf_amplitude,
     mu_x : float
         Estimated mean of the gaussian signal along x axis, in nanometer.
     sigma_yx : int or float
-        Standard deviation of the gaussian along the yx axis, in nanometer.
+        Standard deviation of the gaussian in the yx plan, in nanometer.
     voxel_size_yx : int or float
-        size of a voxel, in nanometer.
-    psf_amplitude : float
-        Estimated pixel intensity of a spot.
-    psf_background : float
+        Size of a voxel in the yx plan, in nanometer.
+    amplitude : float
+        Estimated pixel intensity of the gaussian signal.
+    background : float
         Estimated pixel intensity of the background.
-    precomputed : Tuple[np.ndarray]
-        Tuple with one tables of precomputed values for the erf, with shape
+    precomputed : Tuple[np.ndarray] or None
+        Tuple with tables of precomputed values for the erf, with shape
         (nb_value, 2). One table per dimension.
 
     Returns
@@ -999,19 +728,21 @@ def gaussian_2d(grid, mu_y, mu_x, sigma_yx, voxel_size_yx, psf_amplitude,
         meshgrid_x_plus = meshgrid_x + voxel_size_yx / 2
 
         # compute gaussian function for each voxel (i, j) of surface S
-        voxel_integral_y = _rescaled_erf(low=meshgrid_y_minus,
-                                         high=meshgrid_y_plus,
-                                         mu=mu_y,
-                                         sigma=sigma_yx)
-        voxel_integral_x = _rescaled_erf(low=meshgrid_x_minus,
-                                         high=meshgrid_x_plus,
-                                         mu=mu_x,
-                                         sigma=sigma_yx)
+        voxel_integral_y = _rescaled_erf(
+            low=meshgrid_y_minus,
+            high=meshgrid_y_plus,
+            mu=mu_y,
+            sigma=sigma_yx)
+        voxel_integral_x = _rescaled_erf(
+            low=meshgrid_x_minus,
+            high=meshgrid_x_plus,
+            mu=mu_x,
+            sigma=sigma_yx)
 
     # compute 2-d gaussian values
-    factor = psf_amplitude / (voxel_size_yx ** 2)
+    factor = amplitude / (voxel_size_yx ** 2)
     voxel_integral = voxel_integral_y * voxel_integral_x
-    values = psf_background + factor * voxel_integral
+    values = background + factor * voxel_integral
 
     return values
 
@@ -1064,9 +795,9 @@ def _fit_gaussian(f, grid, image_spot, p0, lower_bound=None, upper_bound=None):
         A 3-d or 2-d image with detected spot and shape (z, y, x) or (y, x).
     p0 : List
         List of parameters to estimate.
-    lower_bound : List
+    lower_bound : List or None
         List of lower bound values for the different parameters.
-    upper_bound : List
+    upper_bound : List or None
         List of upper bound values for the different parameters.
 
     Returns
@@ -1094,21 +825,21 @@ def _fit_gaussian(f, grid, image_spot, p0, lower_bound=None, upper_bound=None):
     return popt, pcov
 
 
-def precompute_erf(voxel_size_z=None, voxel_size_yx=100, sigma_z=None,
-                   sigma_yx=200, max_grid=200):
+def precompute_erf(ndim, voxel_size, sigma, max_grid=200):
     """Precompute different values for the erf with a nanometer resolution.
 
     Parameters
     ----------
-    voxel_size_z : float or int or None
-        Height of a voxel, in nanometer. If None, we consider a 2-d erf.
-    voxel_size_yx : float or int
-        size of a voxel, in nanometer.
-    sigma_z : float or int or None
-        Standard deviation of the gaussian along the z axis, in nanometer. If
-        None, we consider a 2-d erf.
-    sigma_yx : float or int
-        Standard deviation of the gaussian along the yx axis, in nanometer.
+    ndim : int
+        Number of dimensions to consider (2 or 3).
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    sigma : int, float, Tuple(int, float) or List(int, float)
+        Standard deviation of the gaussian, in nanometer. One value per
+        spatial dimension (zyx or yx dimensions). If it's a scalar, the same
+        value is applied to every dimensions.
     max_grid : int
         Maximum size of the grid on which we precompute the erf, in pixel.
 
@@ -1121,72 +852,90 @@ def precompute_erf(voxel_size_z=None, voxel_size_yx=100, sigma_z=None,
 
     """
     # check parameters
-    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)
+    stack.check_parameter(
+        ndim=int,
+        voxel_size=(int, float, tuple, list),
+        sigma=(int, float, tuple, list),
+        max_grid=int)
+    if ndim not in [2, 3]:
+        raise ValueError(
+            "Number of dimensions requested should be 2 or 3, "
+            "not {0}.".format(ndim))
+
+    # check consistency between parameters
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(sigma, (tuple, list)):
+        if len(sigma) != ndim:
+            raise ValueError(
+                "'sigma' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        sigma = (sigma,) * ndim
 
     # build a grid with a spatial resolution of 1 nm and a size of
     # max_grid * resolution nm
-    max_size_yx = np.ceil(max_grid * voxel_size_yx).astype(np.int64)
+    max_size_yx = np.ceil(max_grid * voxel_size[-1]).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,
-                          mu=mu_y,
-                          sigma=sigma_yx)
-    erf_x = _rescaled_erf(low=xx - voxel_size_yx / 2,
-                          high=xx + voxel_size_yx / 2,
-                          mu=mu_x,
-                          sigma=sigma_yx)
-
+    erf_y = _rescaled_erf(
+        low=yy - voxel_size[-1] / 2,
+        high=yy + voxel_size[-1] / 2,
+        mu=mu_y,
+        sigma=sigma[-1])
+    erf_x = _rescaled_erf(
+        low=xx - voxel_size[-1] / 2,
+        high=xx + voxel_size[-1] / 2,
+        mu=mu_x,
+        sigma=sigma[-1])
     table_erf_y = np.array([yy, erf_y]).T
     table_erf_x = np.array([xx, erf_x]).T
 
     # precompute erf along z axis if needed
-    if voxel_size_z is None or sigma_z is None:
+    if ndim == 2:
         return table_erf_y, table_erf_x
 
     else:
-        max_size_z = np.ceil(max_grid * voxel_size_z).astype(np.int64)
+        max_size_z = np.ceil(max_grid * voxel_size[0]).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,
-                              mu=mu_z,
-                              sigma=sigma_z)
+        erf_z = _rescaled_erf(
+            low=zz - voxel_size[0] / 2,
+            high=zz + voxel_size[0] / 2,
+            mu=mu_z,
+            sigma=sigma[0])
         table_erf_z = np.array([zz, erf_z]).T
         return table_erf_z, table_erf_y, table_erf_x
 
 
 # ### Subpixel fitting ###
 
-def fit_subpixel(image, spots, voxel_size_z=None, voxel_size_yx=100,
-                 psf_z=None, psf_yx=200):
+def fit_subpixel(image, spots, voxel_size, spot_radius):
     """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
+    spots : np.ndarray
         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, 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.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    spot_radius : int, float, Tuple(int, float) or List(int, float)
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions.
 
     Returns
     -------
@@ -1196,32 +945,43 @@ def fit_subpixel(image, spots, voxel_size_z=None, voxel_size_yx=100,
 
     """
     # 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))
-
-    # check number of dimensions
+    stack.check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    stack.check_array(spots, ndim=2, dtype=[np.float64, np.int64])
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        spot_radius=(int, float, tuple, list))
+
+    # 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
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(spot_radius, (tuple, list)):
+        if len(spot_radius) != ndim:
+            raise ValueError(
+                "'spot_radius' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        spot_radius = (spot_radius,) * ndim
 
-    # compute radius
-    radius = stack.get_radius(voxel_size_z, voxel_size_yx, psf_z, psf_yx)
+    # compute radius used to crop spot image
+    radius_pixel = get_object_radius_pixel(
+        voxel_size_nm=voxel_size,
+        object_radius_nm=spot_radius,
+        ndim=ndim)
+    radius = [np.sqrt(ndim) * r for r in radius_pixel]
+    radius = tuple(radius)
 
     # loop over every spot
     spots_subpixel = []
@@ -1230,13 +990,18 @@ def fit_subpixel(image, spots, voxel_size_z=None, voxel_size_yx=100,
         # fit subpixel coordinates
         if ndim == 3:
             subpixel_coord = _fit_subpixel_3d(
-                image, coord, radius,
-                voxel_size_z, voxel_size_yx, psf_z, psf_yx)
-
+                image=image, coord=coord,
+                radius_to_crop=radius,
+                voxel_size_z=voxel_size[0],
+                voxel_size_yx=voxel_size[-1],
+                spot_radius_z=spot_radius[0],
+                spot_radius_yx=spot_radius[-1])
         else:
             subpixel_coord = _fit_subpixel_2d(
-                image, coord, radius, voxel_size_yx, psf_yx)
-
+                image=image, coord=coord,
+                radius_to_crop=radius,
+                voxel_size_yx=voxel_size[-1],
+                spot_radius_yx=spot_radius[-1])
         spots_subpixel.append(subpixel_coord)
 
     # format results
@@ -1245,9 +1010,9 @@ def fit_subpixel(image, spots, voxel_size_z=None, voxel_size_yx=100,
     return spots_subpixel
 
 
-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.
+def _fit_subpixel_3d(image, coord, radius_to_crop, voxel_size_z, voxel_size_yx,
+                     spot_radius_z, spot_radius_yx):
+    """Fit a 3-d gaussian on a detected spot.
 
     Parameters
     ----------
@@ -1256,18 +1021,17 @@ def _fit_subpixel_3d(image, coord, radius, voxel_size_z, voxel_size_yx, psf_z,
     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.
+    radius_to_crop : Tuple[float]
+        Enlarged radius of a spot, in pixel, used to crop an image around it.
+        Tuple with 3 scalars (one per dimension zyx).
+    voxel_size_z : int or float
+        Size 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.
-    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.
+        Size of a voxel in the yx plan, in nanometer.
+    spot_radius_z : int or float
+        Radius of the spot along the z axis, in nanometer.
+    spot_radius_yx : int or float
+        Radius of the spot in the yx plan, in nanometer.
 
     Returns
     -------
@@ -1278,15 +1042,20 @@ def _fit_subpixel_3d(image, coord, radius, voxel_size_z, voxel_size_yx, psf_z,
     """
     # extract spot image
     image_spot, bbox_low = _get_spot_volume(
-        image, coord[0], coord[1], coord[2], radius[0], radius[1])
+        image=image,
+        spot_z=coord[0],
+        spot_y=coord[1],
+        spot_x=coord[2],
+        radius_z=radius_to_crop[0],
+        radius_yx=radius_to_crop[-1])
 
     # fit gaussian
     try:
-        parameters = modelize_spot(image_spot,
-                                   voxel_size_z=voxel_size_z,
-                                   voxel_size_yx=voxel_size_yx,
-                                   psf_z=psf_z, psf_yx=psf_yx,
-                                   return_coord=True)
+        parameters = modelize_spot(
+            reference_spot=image_spot,
+            voxel_size=(voxel_size_z, voxel_size_yx, voxel_size_yx),
+            spot_radius=(spot_radius_z, spot_radius_yx, spot_radius_yx),
+            return_coord=True)
 
         # format coordinates and ensure it is fitted within the spot image
         z_max, y_max, x_max = image_spot.shape
@@ -1314,8 +1083,9 @@ def _fit_subpixel_3d(image, coord, radius, voxel_size_z, voxel_size_yx, psf_z,
     return new_coord
 
 
-def _fit_subpixel_2d(image, coord, radius, voxel_size_yx, psf_yx):
-    """Fit a gaussian in a 2-d image.
+def _fit_subpixel_2d(image, coord, radius_to_crop, voxel_size_yx,
+                     spot_radius_yx):
+    """Fit a 2-d gaussian on a detected spot.
 
     Parameters
     ----------
@@ -1324,13 +1094,13 @@ def _fit_subpixel_2d(image, coord, radius, voxel_size_yx, psf_yx):
     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.
+    radius_to_crop : Tuple[float]
+        Enlarged radius of a spot, in pixel, used to crop an image around it.
+        Tuple with 2 scalars (one per dimension yx).
     voxel_size_yx : int or float
-        Size of a voxel on the yx plan, in nanometer.
-    psf_yx : int or float
-        Theoretical size of the PSF emitted by a spot in the yx plan,
-        in nanometer.
+        Size of a voxel in the yx plan, in nanometer.
+    spot_radius_yx : int or float
+        Radius of the spot in the yx plan, in nanometer.
 
     Returns
     -------
@@ -1341,15 +1111,18 @@ def _fit_subpixel_2d(image, coord, radius, voxel_size_yx, psf_yx):
     """
     # extract spot image
     image_spot, bbox_low = _get_spot_surface(
-        image, coord[0], coord[1], radius[0])
+        image=image,
+        spot_y=coord[0],
+        spot_x=coord[1],
+        radius_yx=radius_to_crop[-1])
 
     # fit gaussian
     try:
-        parameters = modelize_spot(image_spot,
-                                   voxel_size_z=None,
-                                   voxel_size_yx=voxel_size_yx,
-                                   psf_z=None, psf_yx=psf_yx,
-                                   return_coord=True)
+        parameters = modelize_spot(
+            reference_spot=image_spot,
+            voxel_size=(voxel_size_yx, voxel_size_yx),
+            spot_radius=(spot_radius_yx, spot_radius_yx),
+            return_coord=True)
 
         # format coordinates and ensure it is fitted within the spot image
         y_max, x_max = image_spot.shape
diff --git a/bigfish/detection/utils.py b/bigfish/detection/utils.py
new file mode 100644
index 00000000..919bee1c
--- /dev/null
+++ b/bigfish/detection/utils.py
@@ -0,0 +1,685 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Utility functions for bigfish.detection subpackage.
+"""
+
+import warnings
+
+import numpy as np
+
+import bigfish.stack as stack
+
+
+# ### Pixel - nanometer conversion
+
+def convert_spot_coordinates(spots, voxel_size):
+    """Convert spots coordinates from pixel to nanometer.
+
+    Parameters
+    ----------
+    spots : np.ndarray
+        Coordinates of the detected spots with shape (nb_spots, 3) or
+        (nb_spots, 2).
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+
+    Returns
+    -------
+    spots_nanometer : np.ndarray
+        Coordinates of the detected spots with shape (nb_spots, 3) or
+        (nb_spots, 3), in nanometer.
+
+    """
+    # check parameters
+    stack.check_parameter(voxel_size=(int, float, tuple, list))
+    stack.check_array(spots, ndim=2, dtype=[np.float64, np.int64])
+    dtype = spots.dtype
+
+    # check consistency between parameters
+    ndim = spots.shape[1]
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError("'voxel_size' must be a scalar or a sequence "
+                             "with {0} elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+
+    # convert spots coordinates in nanometer
+    spots_nanometer = spots.copy()
+    if ndim == 3:
+        spots_nanometer[:, 0] *= voxel_size[0]
+        spots_nanometer[:, 1:] *= voxel_size[-1]
+
+    else:
+        spots_nanometer *= voxel_size[-1]
+    spots_nanometer = spots_nanometer.astype(dtype)
+
+    return spots_nanometer
+
+
+def get_object_radius_pixel(voxel_size_nm, object_radius_nm, ndim):
+    """Convert the object radius in pixel.
+
+    When the object considered is a spot this value can be interpreted as the
+    standard deviation of the spot PSF, in pixel. For any object modelled with
+    a gaussian signal, this value can be interpreted as the standard deviation
+    of the gaussian.
+
+    Parameters
+    ----------
+    voxel_size_nm : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    object_radius_nm : int, float, Tuple(int, float) or List(int, float)
+        Radius of the object, in nanometer. One value per spatial dimension
+        (zyx or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions.
+    ndim : int
+        Number of spatial dimension to consider.
+
+    Returns
+    -------
+    object_radius_px : Tuple[float]
+        Radius of the object in pixel, one element per dimension (zyx or yx
+        dimensions).
+
+    """
+    # check parameters
+    stack.check_parameter(
+        voxel_size_nm=(int, float, tuple, list),
+        object_radius_nm=(int, float, tuple, list),
+        ndim=int)
+
+    # check consistency between parameters
+    if isinstance(voxel_size_nm, (tuple, list)):
+        if len(voxel_size_nm) != ndim:
+            raise ValueError("'voxel_size_nm' must be a scalar or a sequence "
+                             "with {0} elements.".format(ndim))
+    else:
+        voxel_size_nm = (voxel_size_nm,) * ndim
+    if isinstance(object_radius_nm, (tuple, list)):
+        if len(object_radius_nm) != ndim:
+            raise ValueError("'object_radius_nm' must be a scalar or a "
+                             "sequence with {0} elements.".format(ndim))
+    else:
+        object_radius_nm = (object_radius_nm,) * ndim
+
+    # get radius in pixel
+    object_radius_px = [b / a for a, b in zip(voxel_size_nm, object_radius_nm)]
+    object_radius_px = tuple(object_radius_px)
+
+    return object_radius_px
+
+
+def get_object_radius_nm(voxel_size_nm, object_radius_px, ndim):
+    """Convert the object radius in nanometer.
+
+    When the object considered is a spot this value can be interpreted as the
+    standard deviation of the spot PSF, in nanometer. For any object modelled
+    with a gaussian signal, this value can be interpreted as the standard
+    deviation of the gaussian.
+
+    Parameters
+    ----------
+    voxel_size_nm : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    object_radius_px : int, float, Tuple(int, float) or List(int, float)
+        Radius of the object, in pixel. One value per spatial dimension
+        (zyx or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions.
+    ndim : int
+        Number of spatial dimension to consider.
+
+    Returns
+    -------
+    object_radius_nm : Tuple[float]
+        Radius of the object in nanometer, one element per dimension (zyx or yx
+        dimensions).
+
+    """
+    # check parameters
+    stack.check_parameter(
+        voxel_size_nm=(int, float, tuple, list),
+        object_radius_px=(int, float, tuple, list),
+        ndim=int)
+
+    # check consistency between parameters
+    if isinstance(voxel_size_nm, (tuple, list)):
+        if len(voxel_size_nm) != ndim:
+            raise ValueError("'voxel_size_nm' must be a scalar or a sequence "
+                             "with {0} elements.".format(ndim))
+    else:
+        voxel_size_nm = (voxel_size_nm,) * ndim
+    if isinstance(object_radius_px, (tuple, list)):
+        if len(object_radius_px) != ndim:
+            raise ValueError("'object_radius_px' must be a scalar or a "
+                             "sequence with {0} elements.".format(ndim))
+    else:
+        object_radius_px = (object_radius_px,) * ndim
+
+    # get radius in pixel
+    object_radius_nm = [a * b for a, b in zip(voxel_size_nm, object_radius_px)]
+    object_radius_nm = tuple(object_radius_nm)
+
+    return object_radius_nm
+
+
+# ### Reference spot ###
+
+def build_reference_spot(image, spots, voxel_size, spot_radius, alpha=0.5):
+    """Build a median or mean spot in 3 or 2 dimensions as reference.
+
+    Reference spot is computed from a sample of uncropped detected spots. If
+    such sample is not possible, an empty frame is returned.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x) or (y, x).
+    spots : np.ndarray
+        Coordinate of the spots with shape (nb_spots, 3) for 3-d images or
+        (nb_spots, 2) for 2-d images.
+    voxel_size : int, float, Tuple(int, float) or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    spot_radius : int, float, Tuple(int, float) or List(int, float)
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions.
+    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
+        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.float64, np.int64])
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        spot_radius=(int, float, tuple, list),
+        alpha=(int, float))
+    if alpha < 0 or alpha > 1:
+        raise ValueError("'alpha' should be a value between 0 and 1, not {0}"
+                         .format(alpha))
+
+    # check consistency between parameters
+    ndim = image.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 isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(spot_radius, (tuple, list)):
+        if len(spot_radius) != ndim:
+            raise ValueError(
+                "'spot_radius' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        spot_radius = (spot_radius,) * ndim
+
+    # compute radius used to crop spot image
+    radius_pixel = get_object_radius_pixel(
+        voxel_size_nm=voxel_size,
+        object_radius_nm=spot_radius,
+        ndim=ndim)
+    radius = [np.sqrt(ndim) * r for r in radius_pixel]
+    radius = tuple(radius)
+
+    # build reference spot
+    if image.ndim == 3:
+        reference_spot = _build_reference_spot_3d(image, spots, radius, alpha)
+    else:
+        reference_spot = _build_reference_spot_2d(image, spots, radius, alpha)
+
+    return reference_spot
+
+
+def _build_reference_spot_3d(image, spots, radius, alpha):
+    """Build a median or mean spot in 3 dimensions as reference.
+
+    Reference spot is computed from a sample of uncropped detected spots. If
+    such sample is not possible, an empty frame is returned.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x).
+    spots : np.ndarray, np.int64
+        Coordinate of the spots with shape (nb_spots, 3) for 3-d images.
+    radius : Tuple[float]
+        Radius in pixel of the detected spots, one element per dimension.
+    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.
+
+    Returns
+    -------
+    reference_spot : np.ndarray
+        Reference spot in 3-d.
+
+    """
+    # get a rounded radius for each dimension
+    radius_z = np.ceil(radius[0]).astype(np.int64)
+    z_shape = radius_z * 2 + 1
+    radius_yx = np.ceil(radius[-1]).astype(np.int64)
+    yx_shape = radius_yx * 2 + 1
+
+    # randomly choose some spots to aggregate
+    indices = [i for i in range(spots.shape[0])]
+    np.random.shuffle(indices)
+    indices = indices[:min(2000, spots.shape[0])]
+    candidate_spots = spots[indices, :]
+
+    # collect area around each spot
+    l_reference_spot = []
+    for i_spot in range(candidate_spots.shape[0]):
+
+        # get spot coordinates
+        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)
+
+        # keep images that are not cropped by the borders
+        if image_spot.shape == (z_shape, yx_shape, yx_shape):
+            l_reference_spot.append(image_spot)
+
+    # if not enough spots are detected
+    if len(l_reference_spot) <= 30:
+        warnings.warn("Problem occurs during the computation of a reference "
+                      "spot. Not enough (uncropped) spots have been detected.",
+                      UserWarning)
+    if len(l_reference_spot) == 0:
+        reference_spot = np.zeros(
+            (z_shape, yx_shape, yx_shape), dtype=image.dtype)
+        return reference_spot
+
+    # project the different spot images
+    l_reference_spot = np.stack(l_reference_spot, axis=0)
+    alpha_ = alpha * 100
+    reference_spot = np.percentile(l_reference_spot, alpha_, axis=0)
+    reference_spot = reference_spot.astype(image.dtype)
+
+    return reference_spot
+
+
+def _get_spot_volume(image, spot_z, spot_y, spot_x, radius_z, radius_yx):
+    """Get a subimage of a detected spot in 3 dimensions.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (z, y, x).
+    spot_z : np.int64
+        Coordinate of the detected spot along the z axis.
+    spot_y : np.int64
+        Coordinate of the detected spot along the y axis.
+    spot_x : np.int64
+        Coordinate of the detected spot along the x axis.
+    radius_z : int
+        Radius in pixel of the detected spot, along the z axis.
+    radius_yx : int
+        Radius in pixel of the detected spot, on the yx plan.
+
+    Returns
+    -------
+    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
+    z_spot_min = max(0, int(spot_z - radius_z))
+    z_spot_max = min(image.shape[0], int(spot_z + radius_z))
+    y_spot_min = max(0, int(spot_y - radius_yx))
+    y_spot_max = min(image.shape[1], int(spot_y + radius_yx))
+    x_spot_min = max(0, int(spot_x - radius_yx))
+    x_spot_max = min(image.shape[2], int(spot_x + radius_yx))
+
+    # get the volume of the spot
+    image_spot = image[z_spot_min:z_spot_max + 1,
+                       y_spot_min:y_spot_max + 1,
+                       x_spot_min:x_spot_max + 1]
+
+    return image_spot, (z_spot_min, y_spot_min, x_spot_min)
+
+
+def _build_reference_spot_2d(image, spots, radius, alpha):
+    """Build a median or mean spot in 2 dimensions as reference.
+
+    Reference spot is computed from a sample of uncropped detected spots. If
+    such sample is not possible, an empty frame is returned.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (y, x).
+    spots : np.ndarray, np.int64
+        Coordinate of the spots with shape (nb_spots, 2) for 2-d images.
+    radius : Tuple[float]
+        Radius in pixel of the detected spots, one element per dimension.
+    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.
+
+    Returns
+    -------
+    reference_spot : np.ndarray
+        Reference spot in 2-d.
+
+    """
+    # get a rounded radius for each dimension
+    radius_yx = np.ceil(radius[-1]).astype(np.int64)
+    yx_shape = radius_yx * 2 + 1
+
+    # randomly choose some spots to aggregate
+    indices = [i for i in range(spots.shape[0])]
+    np.random.shuffle(indices)
+    indices = indices[:min(2000, spots.shape[0])]
+    candidate_spots = spots[indices, :]
+
+    # collect area around each spot
+    l_reference_spot = []
+    for i_spot in range(candidate_spots.shape[0]):
+
+        # get spot coordinates
+        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)
+
+        # keep images that are not cropped by the borders
+        if image_spot.shape == (yx_shape, yx_shape):
+            l_reference_spot.append(image_spot)
+
+    # if not enough spots are detected
+    if len(l_reference_spot) <= 30:
+        warnings.warn("Problem occurs during the computation of a reference "
+                      "spot. Not enough (uncropped) spots have been detected.",
+                      UserWarning)
+    if len(l_reference_spot) == 0:
+        reference_spot = np.zeros((yx_shape, yx_shape), dtype=image.dtype)
+        return reference_spot
+
+    # project the different spot images
+    l_reference_spot = np.stack(l_reference_spot, axis=0)
+    alpha_ = alpha * 100
+    reference_spot = np.percentile(l_reference_spot, alpha_, axis=0)
+    reference_spot = reference_spot.astype(image.dtype)
+
+    return reference_spot
+
+
+def _get_spot_surface(image, spot_y, spot_x, radius_yx):
+    """Get a subimage of a detected spot in 2 dimensions.
+
+    Parameters
+    ----------
+    image : np.ndarray
+        Image with shape (y, x).
+    spot_y : np.int64
+        Coordinate of the detected spot along the y axis.
+    spot_x : np.int64
+        Coordinate of the detected spot along the x axis.
+    radius_yx : int
+        Radius in pixel of the detected spot, on the yx plan.
+
+    Returns
+    -------
+    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
+    y_spot_min = max(0, int(spot_y - radius_yx))
+    y_spot_max = min(image.shape[0], int(spot_y + radius_yx))
+    x_spot_min = max(0, int(spot_x - radius_yx))
+    x_spot_max = min(image.shape[1], int(spot_x + radius_yx))
+
+    # get the surface of the spot
+    image_spot = image[y_spot_min:y_spot_max + 1,
+                       x_spot_min:x_spot_max + 1]
+
+    return image_spot, (y_spot_min, x_spot_min)
+
+
+# ### SNR ###
+
+def compute_snr_spots(image, spots, voxel_size, spot_radius):
+    """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
+        Coordinate of the spots, with shape (nb_spots, 3) or (nb_spots, 2).
+        One coordinate per dimension (zyx or yx coordinates).
+    voxel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions. Not used if 'log_kernel_size' and 'minimum_distance' are
+        provided.
+    spot_radius : int, float, Tuple(int, float), List(int, float) or None
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions. Not used if 'log_kernel_size' and 'minimum_distance'
+        are provided.
+
+    Returns
+    -------
+    snr : float
+        Median signal-to-noise ratio computed for every spots.
+
+    """
+    # check parameters
+    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])
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        spot_radius=(int, float, tuple, list))
+
+    # check consistency between parameters
+    ndim = image.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 isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+    if isinstance(spot_radius, (tuple, list)):
+        if len(spot_radius) != ndim:
+            raise ValueError(
+                "'spot_radius' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        spot_radius = (spot_radius,) * ndim
+
+    # 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 radius used to crop spot image
+    radius_pixel = get_object_radius_pixel(
+        voxel_size_nm=voxel_size,
+        object_radius_nm=spot_radius,
+        ndim=ndim)
+    radius_signal_ = [np.sqrt(ndim) * r for r in radius_pixel]
+    radius_signal_ = tuple(radius_signal_)
+
+    # 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
+
+
+# ### Miscellaneous ###
+
+def get_breaking_point(x, y):
+    """Select the x-axis value where a L-curve has a kink.
+
+    Assuming a L-curve from A to B, the 'breaking_point' is the more distant
+    point to the segment [A, B].
+
+    Parameters
+    ----------
+    x : np.array
+        X-axis values.
+    y : np.array
+        Y-axis values.
+
+    Returns
+    -------
+    breaking_point : float
+        X-axis value at the kink location.
+    x : np.array
+        X-axis values.
+    y : np.array
+        Y-axis values.
+
+    """
+    # check parameters
+    stack.check_array(x, ndim=1, dtype=[np.float64, np.int64])
+    stack.check_array(y, ndim=1, dtype=[np.float64, np.int64])
+
+    # select threshold where curve break
+    slope = (y[-1] - y[0]) / len(y)
+    y_grad = np.gradient(y)
+    m = list(y_grad >= slope)
+    j = m.index(False)
+    m = m[j:]
+    x = x[j:]
+    y = y[j:]
+    if True in m:
+        i = m.index(True)
+    else:
+        i = -1
+    breaking_point = float(x[i])
+
+    return breaking_point, x, y
diff --git a/bigfish/multistack/__init__.py b/bigfish/multistack/__init__.py
new file mode 100644
index 00000000..f41b38e3
--- /dev/null
+++ b/bigfish/multistack/__init__.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+The bigfish.multistack subpackage includes function to process input and output
+from different channels.
+"""
+
+from .utils import check_recipe
+from .utils import fit_recipe
+from .utils import get_path_from_recipe
+from .utils import get_nb_element_per_dimension
+from .utils import count_nb_fov
+from .utils import check_datamap
+
+from .preprocess import build_stacks
+from .preprocess import build_stack
+from .preprocess import build_stack_no_recipe
+
+from .colocalization import detect_spots_colocalization
+from .colocalization import get_elbow_value_colocalized
+
+from .postprocess import identify_objects_in_region
+from .postprocess import remove_transcription_site
+from .postprocess import match_nuc_cell
+from .postprocess import extract_cell
+from .postprocess import extract_spots_from_frame
+from .postprocess import summarize_extraction_results
+from .postprocess import center_mask_coord
+from .postprocess import from_boundaries_to_surface
+from .postprocess import from_surface_to_boundaries
+from .postprocess import from_binary_to_coord
+from .postprocess import complete_coord_boundaries
+from .postprocess import from_coord_to_frame
+from .postprocess import from_coord_to_surface
+
+
+# TODO complete bigfish.multistack.preprocess documentation
+
+_utils = [
+    "check_recipe",
+    "fit_recipe",
+    "get_path_from_recipe",
+    "get_nb_element_per_dimension",
+    "count_nb_fov",
+    "check_datamap"]
+
+_preprocess = [
+    "build_stacks",
+    "build_stack",
+    "build_stack_no_recipe"]
+
+_colocalization = [
+    "detect_spots_colocalization",
+    "get_elbow_value_colocalized"]
+
+_postprocess = [
+    "identify_objects_in_region",
+    "remove_transcription_site",
+    "match_nuc_cell",
+    "extract_cell",
+    "extract_spots_from_frame",
+    "summarize_extraction_results",
+    "center_mask_coord",
+    "from_boundaries_to_surface",
+    "from_surface_to_boundaries",
+    "from_binary_to_coord",
+    "complete_coord_boundaries",
+    "from_coord_to_frame",
+    "from_coord_to_surface"]
+
+__all__ = _utils + _preprocess + _colocalization + _postprocess
diff --git a/bigfish/multistack/colocalization.py b/bigfish/multistack/colocalization.py
new file mode 100644
index 00000000..b2ae0911
--- /dev/null
+++ b/bigfish/multistack/colocalization.py
@@ -0,0 +1,253 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Functions to detect colocalized spots in 2-d and 3-d.
+"""
+
+import numpy as np
+
+import bigfish.stack as stack
+import bigfish.detection as detection
+
+from scipy.spatial.distance import cdist
+from scipy.optimize import linear_sum_assignment
+from scipy.signal import savgol_filter
+
+
+# TODO process multiple images together
+
+# ### Main function ###
+
+def detect_spots_colocalization(spots_1, spots_2, voxel_size, threshold=None,
+                                return_indices=False, return_threshold=False):
+    """Detect colocalized spots between two arrays of spot coordinates
+    'spots_1' and 'spots_2'. Pairs of spots below a specific threshold are
+    defined as colocalized.
+
+    Parameters
+    ----------
+    spots_1 : np.ndarray
+        Coordinates of the spots 1 with shape (nb_spots_1, 3) or
+        (nb_spots_1, 2).
+    spots_2 : np.ndarray
+        Coordinates of the spots 2 with shape (nb_spots_2, 3) or
+        (nb_spots_2, 2).
+    voxel_size : int, float, Tuple(int, float), or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    threshold : int, float or None
+        A threshold to discriminate colocalized spots from distant ones. If
+        None, an optimal threshold is selected automatically.
+    return_indices : bool
+        Return the indices of the colocalized spots within 'spots_1' and
+        'spots_2'.
+    return_threshold : bool
+        Return the threshold used to detect colocalized spots.
+
+    Returns
+    -------
+    spots_1_colocalized : np.ndarray
+        Coordinates of the colocalized spots from 'spots_1' with shape
+        (nb_colocalized_spots,).
+    spots_2_colocalized : np.ndarray
+        Coordinates of the colocalized spots from 'spots_2'with shape
+        (nb_colocalized_spots,).
+    distances : np.ndarray, np.float64
+        Distance matrix between spots with shape (nb_colocalized_spots,).
+    indices_1 : np.ndarray, np.int64
+        Indices of the colocalized spots in 'spots_1' with shape
+        (nb_colocalized_spots,). Optional.
+    indices_2 : np.ndarray, np.int64
+        Indices of the colocalized spots in 'spots_2' with shape
+        (nb_colocalized_spots,). Optional.
+    threshold : int or float
+        Threshold used to discriminate colocalized spots from distant ones.
+        Optional.
+
+    """
+    # check parameters
+    stack.check_parameter(
+        voxel_size=(int, float, tuple, list),
+        threshold=(float, int, type(None)),
+        return_indices=bool,
+        return_threshold=bool)
+
+    # check spots coordinates
+    stack.check_array(spots_1, ndim=2, dtype=[np.float64, np.int64])
+    stack.check_array(spots_2, ndim=2, dtype=[np.float64, np.int64])
+
+    # convert spots coordinates in nanometer
+    spots_1_nanometer = detection.convert_spot_coordinates(
+        spots=spots_1,
+        voxel_size=voxel_size)
+    spots_2_nanometer = detection.convert_spot_coordinates(
+        spots=spots_2,
+        voxel_size=voxel_size)
+
+    # compute distance matrix between spots
+    distance_matrix = cdist(spots_1_nanometer, spots_2_nanometer)
+
+    # assign spots based on their euclidean distance
+    indices_1, indices_2 = linear_sum_assignment(distance_matrix)
+
+    # get distance between colocalized spots
+    distances = distance_matrix[indices_1, indices_2]
+
+    # keep colocalized spots under a specific threshold
+    if threshold is None:
+        threshold = _automated_threshold_setting_colocalization(distances)
+
+    # keep colocalized spots within a specific distance
+    mask = distances <= threshold
+    indices_1 = indices_1[mask]
+    indices_2 = indices_2[mask]
+    distances = distances[mask]
+
+    # get colocalized spots
+    spots_1_colocalized = spots_1[indices_1, ...]
+    spots_2_colocalized = spots_2[indices_2, ...]
+
+    # return indices and threshold or not
+    if return_indices and return_threshold:
+        return (spots_1_colocalized, spots_2_colocalized, distances, indices_1,
+                indices_2, threshold)
+    elif return_indices and not return_threshold:
+        return (spots_1_colocalized, spots_2_colocalized, distances, indices_1,
+                indices_2)
+    elif not return_indices and return_threshold:
+        return spots_1_colocalized, spots_2_colocalized, distances, threshold
+    else:
+        return spots_1_colocalized, spots_2_colocalized, distances
+
+
+def _automated_threshold_setting_colocalization(distances):
+    """Automatically set the optimal threshold to detect colocalized spots
+    between two arrays of spot coordinates 'spots_1' and 'spots_2'.
+
+    Parameters
+    ----------
+    distances : np.ndarray, np.float64
+        Distance matrix between spots with shape
+        (min(nb_spots_1, nb_spots_2),).
+
+    Returns
+    -------
+    optimal_threshold : int
+        Optimal threshold to discriminate distant spots and colocalized ones.
+
+    """
+    # get threshold values we want to test
+    min_threshold = distances.min()
+    max_threshold = distances.max() + 10
+    n_candidates = min(int(max_threshold - min_threshold), 10000)
+    thresholds = np.linspace(min_threshold, max_threshold, num=n_candidates)
+
+    # get colocalized spots count
+    nb_colocalized = []
+    for threshold in thresholds:
+        mask = distances <= threshold
+        n = mask.sum()
+        nb_colocalized.append(n)
+    nb_colocalized = np.array(nb_colocalized)
+
+    # select threshold where the break of the distribution is located
+    x = thresholds.copy()
+    y = -nb_colocalized.copy() + nb_colocalized.max()
+    y_smooth = savgol_filter(y, 501, 3, mode="nearest")
+    if y_smooth.size > 0:
+        optimal_threshold, _, _ = detection.get_breaking_point(x, y_smooth)
+
+    # case where no spots were detected
+    else:
+        optimal_threshold = None
+
+    return optimal_threshold
+
+
+def get_elbow_value_colocalized(spots_1, spots_2, voxel_size):
+    """Get values to plot the elbow curve used to automatically set the
+    threshold to detect colocalized spots.
+
+    Parameters
+    ----------
+    spots_1 : np.ndarray
+        Coordinates of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+    spots_2 : np.ndarray
+        Coordinates of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+    voxel_size : int, float, Tuple(int, float), or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+
+    Returns
+    -------
+    thresholds : np.ndarray, np.float64
+        Candidate threshold values.
+    nb_colocalized : np.ndarray, np.float64
+        Colocalized spots count.
+    optimal_threshold : float or None
+        Threshold automatically set.
+
+    """
+    # check parameters
+    stack.check_parameter(voxel_size=(int, float, tuple, list))
+    stack.check_array(spots_1, ndim=2, dtype=[np.float64, np.int64])
+    stack.check_array(spots_2, ndim=2, dtype=[np.float64, np.int64])
+
+    # check consistency between parameters
+    ndim = spots_1.shape[1]
+    if ndim not in [2, 3]:
+        raise ValueError("Spot coordinates should be in 2 or 3 dimensions, "
+                         "not {0}.".format(ndim))
+    if spots_2.shape[1] != ndim:
+        raise ValueError("Spot coordinates should have the same number of "
+                         "dimensions.")
+    if isinstance(voxel_size, (tuple, list)):
+        if len(voxel_size) != ndim:
+            raise ValueError(
+                "'voxel_size' must be a scalar or a sequence with {0} "
+                "elements.".format(ndim))
+    else:
+        voxel_size = (voxel_size,) * ndim
+
+    # convert spots coordinates in nanometer
+    spots_1_nanometer = detection.convert_spot_coordinates(
+        spots=spots_1,
+        voxel_size=voxel_size)
+    spots_2_nanometer = detection.convert_spot_coordinates(
+        spots=spots_2,
+        voxel_size=voxel_size)
+
+    # compute distance matrix between spots
+    distance_matrix = cdist(spots_1_nanometer, spots_2_nanometer)
+
+    # assign spots based on their euclidean distance
+    indices_1, indices_2 = linear_sum_assignment(distance_matrix)
+
+    # get distance between colocalized spots
+    distances = distance_matrix[indices_1, indices_2]
+
+    # get candidate thresholds
+    min_threshold = distances.min()
+    max_threshold = distances.max() + 10
+    n_candidates = min(int(max_threshold - min_threshold), 10000)
+    thresholds = np.linspace(min_threshold, max_threshold, num=n_candidates)
+
+    # get colocalized spots count
+    nb_colocalized = []
+    for threshold in thresholds:
+        mask = distances <= threshold
+        n = mask.sum()
+        nb_colocalized.append(n)
+    nb_colocalized = np.array(nb_colocalized)
+
+    # select threshold where the break of the distribution is located
+    x = thresholds.copy()
+    y = -nb_colocalized.copy() + nb_colocalized.max()
+    y_smooth = savgol_filter(y, 501, 3, mode="nearest")
+    optimal_threshold, _, _ = detection.get_breaking_point(x, y_smooth)
+
+    return thresholds, nb_colocalized, optimal_threshold
diff --git a/bigfish/stack/postprocess.py b/bigfish/multistack/postprocess.py
similarity index 81%
rename from bigfish/stack/postprocess.py
rename to bigfish/multistack/postprocess.py
index 3b2f490b..89ff8feb 100644
--- a/bigfish/stack/postprocess.py
+++ b/bigfish/multistack/postprocess.py
@@ -3,8 +3,8 @@
 # License: BSD 3 clause
 
 """
-Functions used to format and clean any intermediate results loaded in or
-returned by a bigfish method.
+Functions used to format, merge and clean intermediate results from different
+channels processed with bigfish.
 """
 
 import warnings
@@ -13,10 +13,10 @@
 import pandas as pd
 from scipy import ndimage as ndi
 
-from .utils import check_array, check_parameter, get_margin_value
-from .io import save_data_to_csv
+import bigfish.stack as stack
 
-from skimage.measure import regionprops, find_contours
+from skimage.measure import regionprops
+from skimage.measure import find_contours
 from skimage.draw import polygon_perimeter
 
 
@@ -44,14 +44,12 @@ def identify_objects_in_region(mask, coord, ndim):
 
     """
     # check parameters
-    check_parameter(ndim=int)
-    check_array(mask,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.int64,
-                       bool])
-    check_array(coord,
-                ndim=2,
-                dtype=[np.int64, np.float64])
+    stack.check_parameter(ndim=int)
+    stack.check_array(
+        mask,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_array(coord, ndim=2, dtype=[np.int64, np.float64])
 
     # check number of dimensions
     if ndim not in [2, 3]:
@@ -121,9 +119,7 @@ def remove_transcription_site(rna, clusters, nuc_mask, ndim):
 
     """
     # check parameters
-    check_array(rna,
-                ndim=2,
-                dtype=[np.int64, np.float64])
+    stack.check_array(rna, ndim=2, dtype=[np.int64, np.float64])
 
     # discriminate foci from transcription sites
     ts, foci = identify_objects_in_region(
@@ -137,6 +133,123 @@ def remove_transcription_site(rna, clusters, nuc_mask, ndim):
     return rna_out_ts, foci, ts
 
 
+# ### 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 (z, y, x) or (y, x).
+    cell_label : np.ndarray, np.int or np.uint
+        Labelled image of cells with shape (z, y, x) or (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 (z, y, x) or (y, x).
+    new_cell_label : np.ndarray, np.int or np.uint
+        Labelled image of cells with shape (z, y, x) or (y, x).
+
+    """
+    # check parameters
+    stack.check_array(
+        nuc_label,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(
+        cell_label,
+        ndim=[2, 3],
+        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
+
+
 # ### Cell extraction ###
 
 def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
@@ -193,18 +306,25 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
 
     """
     # check parameters
-    check_parameter(ndim=int,
-                    others_coord=(dict, type(None)),
-                    others_image=(dict, type(None)),
-                    remove_cropped_cell=bool,
-                    check_nuc_in_cell=bool)
-    check_array(cell_label, ndim=2, dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_parameter(
+        ndim=int,
+        others_coord=(dict, type(None)),
+        others_image=(dict, type(None)),
+        remove_cropped_cell=bool,
+        check_nuc_in_cell=bool)
+    stack.check_array(
+        cell_label,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64])
     if nuc_label is not None:
-        check_array(nuc_label, ndim=2, dtype=[np.uint8, np.uint16, np.int64])
+        stack.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, np.float64])
+        stack.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])
+        stack.check_array(image, ndim=2, dtype=[np.uint8, np.uint16])
     actual_keys = ["cell_id", "bbox", "cell_coord", "cell_mask", "nuc_coord",
                    "nuc_mask", "rna_coord", "image"]
     if others_coord is not None:
@@ -215,7 +335,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, np.float64])
+            stack.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 "
@@ -232,13 +352,13 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
             else:
                 actual_keys.append(key)
             image_ = others_image[key]
-            check_array(image_, ndim=2, dtype=[np.uint8, np.uint16])
+            stack.check_array(image_, ndim=2, dtype=[np.uint8, np.uint16])
             if image_.shape != image.shape:
                 warnings.warn("Image in 'others_image' does not have the same "
                               "shape ({0}) than original image ({1})."
                               .format(image_.shape, image.shape),
                               UserWarning)
-    if rna_coord.shape[1] < ndim:
+    if rna_coord is not None and rna_coord.shape[1] < ndim:
         warnings.warn("'rna_coord' have less coordinates ({0}) than the "
                       "minimum number of spatial dimension we "
                       "consider ({1}).".format(rna_coord.shape[1], ndim),
@@ -311,7 +431,9 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
         # get coordinates of the spots detected in the cell
         if rna_coord is not None:
             rna_in_cell, _ = identify_objects_in_region(
-                cell_mask, rna_coord, ndim)
+                cell_mask,
+                rna_coord,
+                ndim)
             rna_in_cell[:, ndim - 2] -= min_y
             rna_in_cell[:, ndim - 1] -= min_x
             cell_results["rna_coord"] = rna_in_cell
@@ -321,7 +443,9 @@ def extract_cell(cell_label, ndim, nuc_label=None, rna_coord=None,
             for key in others_coord:
                 array = others_coord[key]
                 element_in_cell, _ = identify_objects_in_region(
-                    cell_mask, array, ndim)
+                    cell_mask,
+                    array,
+                    ndim)
                 element_in_cell[:, ndim - 2] -= min_y
                 element_in_cell[:, ndim - 1] -= min_x
                 cell_results[key] = element_in_cell
@@ -419,12 +543,11 @@ 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, np.float64])
-    check_parameter(z_lim=(tuple, type(None)),
-                    y_lim=(tuple, type(None)),
-                    x_lim=(tuple, type(None)))
+    stack.check_array(spots, ndim=2, dtype=[np.int64, np.float64])
+    stack.check_parameter(
+        z_lim=(tuple, type(None)),
+        y_lim=(tuple, type(None)),
+        x_lim=(tuple, type(None)))
 
     # extract spots
     extracted_spots = spots.copy()
@@ -480,16 +603,17 @@ def summarize_extraction_results(fov_results, ndim, path_output=None):
 
     """
     # check parameters
-    check_parameter(fov_results=list,
-                    ndim=int,
-                    path_output=(str, type(None)))
+    stack.check_parameter(
+        fov_results=list,
+        ndim=int,
+        path_output=(str, type(None)))
 
     # case if no cell were detected
     # TODO make it consistent with the case where there are cells
     if len(fov_results) == 0:
         df = pd.DataFrame({"cell_id": []})
         if path_output is not None:
-            save_data_to_csv(df, path_output)
+            stack.save_data_to_csv(df, path_output)
         return df
 
     # check extra coordinates to summarize
@@ -523,7 +647,9 @@ def summarize_extraction_results(fov_results, ndim, path_output=None):
             if "nuc_mask" in cell_results:
                 nuc_mask = cell_results["nuc_mask"]
                 rna_in_nuc, rna_out_nuc = identify_objects_in_region(
-                    nuc_mask, rna_coord, ndim)
+                    nuc_mask,
+                    rna_coord,
+                    ndim)
                 _nb_rna_in_nuc.append(len(rna_in_nuc))
                 _nb_rna_out_nuc.append(len(rna_out_nuc))
 
@@ -564,12 +690,12 @@ def summarize_extraction_results(fov_results, ndim, path_output=None):
 
     # save dataframe
     if path_output is not None:
-        save_data_to_csv(df, path_output)
+        stack.save_data_to_csv(df, path_output)
 
     return df
 
 
-# ### Segmentation postprocessing ###
+# Postprocessing
 
 def center_mask_coord(main, others=None):
     """Center a 2-d binary mask (surface or boundaries) or a 2-d localization
@@ -600,20 +726,22 @@ def center_mask_coord(main, others=None):
     """
     # TODO allow the case when coordinates do not represent external boundaries
     # check parameters
-    check_array(main,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.int64, bool])
-    check_parameter(others=(list, type(None)))
+    stack.check_array(
+        main,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_parameter(others=(list, type(None)))
     if others is not None and len(others) != 0:
         for x in others:
             if x is None:
                 continue
-            check_array(x,
-                        ndim=2,
-                        dtype=[np.uint8, np.uint16, np.int64, bool])
+            stack.check_array(
+                x,
+                ndim=2,
+                dtype=[np.uint8, np.uint16, np.int64, bool])
 
     # initialize parameter
-    marge = get_margin_value()
+    marge = stack.get_margin_value()
 
     # compute by how much we need to move the main object to center it
     if main.shape[1] == 2:
@@ -689,9 +817,10 @@ def from_boundaries_to_surface(binary_boundaries):
 
     """
     # check parameters
-    check_array(binary_boundaries,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_array(
+        binary_boundaries,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, bool])
 
     # from binary boundaries to binary surface
     binary_surface = ndi.binary_fill_holes(binary_boundaries)
@@ -714,9 +843,10 @@ def from_surface_to_boundaries(binary_surface):
 
     """
     # check parameters
-    check_array(binary_surface,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_array(
+        binary_surface,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, bool])
     original_dtype = binary_surface.dtype
 
     # pad the binary surface in case object if on the edge
@@ -753,9 +883,10 @@ def from_binary_to_coord(binary):
 
     """
     # check parameters
-    check_array(binary,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_array(
+        binary,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, bool])
 
     # we enlarge the binary mask with one pixel to be sure the external
     # boundaries of the object still fit within the frame
@@ -786,9 +917,7 @@ def complete_coord_boundaries(coord):
 
     """
     # check parameters
-    check_array(coord,
-                ndim=2,
-                dtype=[np.int64])
+    stack.check_array(coord, ndim=2, dtype=[np.int64])
 
     # for each array in the list, complete its coordinates using the scikit
     # image method 'polygon_perimeter'
@@ -828,10 +957,10 @@ def from_coord_to_frame(coord, external_coord=True):
 
     """
     # check parameter
-    check_parameter(external_coord=bool)
+    stack.check_parameter(external_coord=bool)
 
     # initialize marge
-    marge = get_margin_value()
+    marge = stack.get_margin_value()
 
     # from 2D coordinates boundaries to binary boundaries
     if external_coord:
@@ -847,7 +976,7 @@ def from_coord_to_frame(coord, external_coord=True):
     return frame_shape, min_y, min_x, marge
 
 
-def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
+def from_coord_to_surface(cell_coord, nuc_coord=None, rna_coord=None,
                           external_coord=True):
     """Convert 2-d coordinates to a binary matrix with the surface of the
     object.
@@ -861,8 +990,8 @@ def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
 
     Parameters
     ----------
-    cyt_coord : np.ndarray, np.int64
-        Array of cytoplasm boundaries coordinates with shape (nb_points, 2).
+    cell_coord : np.ndarray, np.int64
+        Array of cell 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
@@ -873,8 +1002,8 @@ def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
 
     Returns
     -------
-    cyt_surface : np.ndarray, bool
-        Binary image of cytoplasm surface with shape (y, x).
+    cell_surface : np.ndarray, bool
+        Binary image of cell surface with shape (y, x).
     nuc_surface : np.ndarray, bool
         Binary image of nucleus surface with shape (y, x).
     rna_binary : np.ndarray, bool
@@ -884,32 +1013,26 @@ def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
 
     """
     # check parameters
-    check_array(cyt_coord,
-                ndim=2,
-                dtype=[np.int64])
+    stack.check_array(cell_coord, ndim=2, dtype=[np.int64])
     if nuc_coord is not None:
-        check_array(nuc_coord,
-                    ndim=2,
-                    dtype=[np.int64])
+        stack.check_array(nuc_coord, ndim=2, dtype=[np.int64])
     if rna_coord is not None:
-        check_array(rna_coord,
-                    ndim=2,
-                    dtype=[np.int64])
-    check_parameter(external_coord=bool)
+        stack.check_array(rna_coord, ndim=2, dtype=[np.int64])
+    stack.check_parameter(external_coord=bool)
 
     # center coordinates
-    cyt_coord_, [nuc_coord_, rna_coord_] = center_mask_coord(
-        main=cyt_coord,
+    cell_coord_, [nuc_coord_, rna_coord_] = center_mask_coord(
+        main=cell_coord,
         others=[nuc_coord, rna_coord])
 
     # get the binary frame
     frame_shape, min_y, min_x, marge = from_coord_to_frame(
-        coord=cyt_coord_,
+        coord=cell_coord_,
         external_coord=external_coord)
 
     # from coordinates to binary external boundaries
-    cyt_boundaries_ext = np.zeros(frame_shape, dtype=bool)
-    cyt_boundaries_ext[cyt_coord_[:, 0], cyt_coord_[:, 1]] = True
+    cell_boundaries_ext = np.zeros(frame_shape, dtype=bool)
+    cell_boundaries_ext[cell_coord_[:, 0], cell_coord_[:, 1]] = True
     if nuc_coord_ is not None:
         nuc_boundaries_ext = np.zeros(frame_shape, dtype=bool)
         nuc_boundaries_ext[nuc_coord_[:, 0], nuc_coord_[:, 1]] = True
@@ -917,14 +1040,14 @@ def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
         nuc_boundaries_ext = None
 
     # from binary external boundaries to binary external surface
-    cyt_surface_ext = from_boundaries_to_surface(cyt_boundaries_ext)
+    cell_surface_ext = from_boundaries_to_surface(cell_boundaries_ext)
     if nuc_boundaries_ext is not None:
         nuc_surface_ext = from_boundaries_to_surface(nuc_boundaries_ext)
     else:
         nuc_surface_ext = None
 
     # from binary external surface to binary surface
-    cyt_surface = cyt_surface_ext & (~cyt_boundaries_ext)
+    cell_surface = cell_surface_ext & (~cell_boundaries_ext)
     if nuc_surface_ext is not None:
         nuc_surface = nuc_surface_ext & (~nuc_boundaries_ext)
     else:
@@ -943,4 +1066,4 @@ def from_coord_to_surface(cyt_coord, nuc_coord=None, rna_coord=None,
         rna_binary = None
         new_rna_coord = None
 
-    return cyt_surface, nuc_surface, rna_binary, new_rna_coord
+    return cell_surface, nuc_surface, rna_binary, new_rna_coord
diff --git a/bigfish/multistack/preprocess.py b/bigfish/multistack/preprocess.py
new file mode 100644
index 00000000..d70ef104
--- /dev/null
+++ b/bigfish/multistack/preprocess.py
@@ -0,0 +1,637 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Functions used to build 4D or 5D images.
+"""
+
+import numpy as np
+
+import bigfish.stack as stack
+
+from .utils import check_recipe
+from .utils import check_datamap
+from .utils import fit_recipe
+from .utils import get_path_from_recipe
+from .utils import get_nb_element_per_dimension
+from .utils import count_nb_fov
+
+
+# TODO only read in memory one or several channels (and not the entire image)
+# TODO allow new keys to define a recipe
+
+# ### Building stack ###
+
+def build_stacks(data_map, input_dimension=None, sanity_check=False,
+                 return_origin=False):
+    """Generator to build several stacks from recipe-folder pairs.
+
+    To build a stack, a recipe should be linked to a directory including all
+    the files needed to build the stack. The content of the recipe allows to
+    reorganize the different files stored in the directory in order to build
+    a 5-d tensor. If several fields of view (fov) are store in the recipe,
+    several tensors are generated.
+
+    The list 'data_map' takes the form:
+
+        [
+         (recipe_1, path_input_directory_1),
+         (recipe_2, path_input_directory_1),
+         (recipe_3, path_input_directory_1),
+         (recipe_4, path_input_directory_2),
+         ...
+        ]
+
+    The recipe dictionary for one field of view takes the form:
+
+        {
+         "fov": str or List[str], (optional)
+         "z": str or List[str],   (optional)
+         "c": str or List[str],   (optional)
+         "r": str or List[str],   (optional)
+         "ext": str,              (optional)
+         "opt": str,              (optional)
+         "pattern": str
+         }
+
+    - A field of view is defined by a string common to every images belonging
+    to the same field of view ("fov").
+    - At least every images are in 2-d with x and y dimensions. So we need to
+    mention the round-dimension, the channel-dimension and the z-dimension to
+    add ("r", "c" and "z"). For these keys, we provide a list of
+    strings to identify the images to stack.
+    - An extra information to identify the files to stack in the input folder
+    can be provided with the file extension "ext" (usually 'tif' or 'tiff') or
+    an optional morpheme ("opt").
+    - A pattern used to get the filename ("pattern").
+
+    Example 1. Let us assume 3-d images (zyx dimensions) saved as
+    "r03c03f01_405.tif", "r03c03f01_488.tif" and "r03c03f01_561.tif". The first
+    morpheme "r03c03f01" uniquely identifies a 3-d field of view. The second
+    morphemes "405", "488" and "561" identify three different channels we
+    want to stack. There is no round in this experiment. We need to return a
+    tensor with shape (1, 3, z, y, x). Thus, a valid recipe would be:
+
+        {
+         "fov": "r03c03f01",
+         "c": ["405", "488", "561"],
+         "ext": "tif"
+         "pattern": "fov_c.ext"
+         }
+
+    Example 2. Let us assume 2-d images (yx dimensions) saved as
+    "dapi_1.TIFF", "cy3_1.TIFF", "GFP_1.TIFF", "dapi_2.TIFF", "cy3_2.TIFF" and
+    "GFP_2.TIFF". The first morphemes "dapi", "cy3" and "GFP" identify
+    channels. The second morphemes "1" and "2" identify two different fields of
+    view. There is no round and no z dimension in this experiment. We can
+    build two tensors with shape (1, 3, 1, y, x). Thus, a valid recipe would
+    be:
+
+        {
+         "fov": ["1", "2"],
+         "c": ["dapi", "cy3", "GFP"],
+         "ext": "TIFF"
+         "pattern": "c_fov.ext"
+         }
+
+    Parameters
+    ----------
+    data_map : List[tuple]
+        Map between input directories and recipes.
+    input_dimension : int
+        Number of dimensions of the loaded files. Can speed up the function if
+        provided.
+    sanity_check : bool
+        Check the validity of the loaded tensor. Can slow down the function.
+    return_origin : bool
+        Return the input directory and the recipe used to build the stack.
+
+    Returns
+    -------
+    tensor : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+    input_directory : str
+        Path of the input directory from where the tensor is built.
+    recipe : dict
+        Recipe used to build the tensor.
+    i_fov : int
+        Index of the fov to build (for a specific recipe).
+
+    """
+    # check parameters
+    stack.check_parameter(
+        data_map=list,
+        input_dimension=(int, type(None)),
+        sanity_check=bool,
+        return_origin=bool)
+    check_datamap(data_map)
+
+    # load and generate tensors for each recipe-folder pair
+    for recipe, input_folder in data_map:
+
+        # load and generate tensors for each fov stored in a recipe
+        nb_fov = count_nb_fov(recipe)
+        for i_fov in range(nb_fov):
+            tensor = build_stack(
+                recipe,
+                input_folder,
+                input_dimension=input_dimension,
+                sanity_check=sanity_check,
+                i_fov=i_fov)
+            if return_origin:
+                yield tensor, input_folder, recipe, i_fov
+            else:
+                yield tensor
+
+
+def build_stack(recipe, input_folder, input_dimension=None, sanity_check=False,
+                i_fov=0):
+    """Build a 5-d stack from the same field of view (fov).
+
+    The recipe dictionary for one field of view takes the form:
+
+        {
+         "fov": str or List[str], (optional)
+         "z": str or List[str],   (optional)
+         "c": str or List[str],   (optional)
+         "r": str or List[str],   (optional)
+         "ext": str,              (optional)
+         "opt": str,              (optional)
+         "pattern": str
+         }
+
+    - A field of view is defined by a string common to every images belonging
+    to the same field of view ("fov").
+    - At least every images are in 2-d with x and y dimensions. So we need to
+    mention the round-dimension, the channel-dimension and the z-dimension to
+    add ("r", "c" and "z"). For these keys, we provide a list of
+    strings to identify the images to stack.
+    - An extra information to identify the files to stack in the input folder
+    can be provided with the file extension "ext" (usually 'tif' or 'tiff') or
+    an optional morpheme ("opt").
+    - A pattern used to get the filename ("pattern").
+
+    Example 1. Let us assume 3-d images (zyx dimensions) saved as
+    "r03c03f01_405.tif", "r03c03f01_488.tif" and "r03c03f01_561.tif". The first
+    morpheme "r03c03f01" uniquely identifies a 3-d field of view. The second
+    morphemes "405", "488" and "561" identify three different channels we
+    want to stack. There is no round in this experiment. We need to return a
+    tensor with shape (1, 3, z, y, x). Thus, a valid recipe would be:
+
+        {
+         "fov": "r03c03f01",
+         "c": ["405", "488", "561"],
+         "ext": "tif"
+         "pattern": "fov_c.ext"
+         }
+
+    Example 2. Let us assume 2-d images (yx dimensions) saved as
+    "dapi_1.TIFF", "cy3_1.TIFF", "GFP_1.TIFF", "dapi_2.TIFF", "cy3_2.TIFF" and
+    "GFP_2.TIFF". The first morphemes "dapi", "cy3" and "GFP" identify
+    channels. The second morphemes "1" and "2" identify two different fields of
+    view. There is no round and no z dimension in this experiment. We can
+    build two tensors with shape (1, 3, 1, y, x). Thus, a valid recipe would
+    be:
+
+        {
+         "fov": ["1", "2"],
+         "c": ["dapi", "cy3", "GFP"],
+         "ext": "TIFF"
+         "pattern": "c_fov.ext"
+         }
+
+    Parameters
+    ----------
+    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'.
+    input_folder : str
+        Path of the folder containing the images.
+    input_dimension : int
+        Number of dimensions of the loaded files. Can speed up the function if
+        provided.
+    i_fov : int
+        Index of the fov to build.
+    sanity_check : bool
+        Check the validity of the loaded tensor. Can slow down the function.
+
+    Returns
+    -------
+    tensor : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+    # check parameters
+    check_recipe(recipe)
+    stack.check_parameter(
+        input_folder=str,
+        input_dimension=(int, type(None)),
+        i_fov=int,
+        sanity_check=bool)
+
+    # build stack from recipe and tif files
+    tensor = _load_stack(recipe, input_folder, input_dimension, i_fov)
+
+    # check the validity of the loaded tensor
+    if sanity_check:
+        stack.check_array(
+            tensor,
+            dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+                   np.int8, np.int16, np.int32, np.int64,
+                   np.float16, np.float32, np.float64,
+                   bool],
+            ndim=5,
+            allow_nan=False)
+
+    return tensor
+
+
+def _load_stack(recipe, input_folder, input_dimension=None, i_fov=0):
+    """Build a 5-d tensor from the same field of view (fov).
+
+    The function stacks a set of images using a recipe mapping the
+    different images with the dimensions they represent. Each stacking step
+    add a new dimension to the original tensors (eg. we stack 2-d images with
+    the same xy coordinates to get a 3-d image). If the files we need to build
+    a new dimension are not included in the recipe, an empty dimension is
+    added. This operation is repeated until we get a 5-d tensor. We first
+    operate on the z dimension, then the channels and eventually the rounds.
+
+    The recipe dictionary for one field of view takes the form:
+
+        {
+         "fov": str or List[str], (optional)
+         "z": str or List[str],   (optional)
+         "c": str or List[str],   (optional)
+         "r": str or List[str],   (optional)
+         "ext": str,              (optional)
+         "opt": str,              (optional)
+         "pattern": str
+         }
+
+    - A field of view is defined by a string common to every images belonging
+    to the same field of view ("fov").
+    - At least every images are in 2-d with x and y dimensions. So we need to
+    mention the round-dimension, the channel-dimension and the z-dimension to
+    add ("r", "c" and "z"). For these keys, we provide a list of
+    strings to identify the images to stack.
+    - An extra information to identify the files to stack in the input folder
+    can be provided with the file extension "ext" (usually 'tif' or 'tiff') or
+    an optional morpheme ("opt").
+    - A pattern used to get the filename ("pattern").
+
+    Parameters
+    ----------
+    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'.
+    input_folder : str
+        Path of the folder containing the images.
+    input_dimension : int
+        Number of dimensions of the loaded files.
+    i_fov : int
+        Index of the fov to build.
+
+    Returns
+    -------
+    stack : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+    # complete the recipe with unused morphemes
+    recipe = fit_recipe(recipe)
+
+    # if the initial dimension of the files is unknown, we read one of them
+    if input_dimension is None:
+        input_dimension = _get_input_dimension(recipe, input_folder)
+
+    # get the number of elements to stack per dimension
+    nb_r, nb_c, nb_z = get_nb_element_per_dimension(recipe)
+
+    # we stack our files according to their initial dimension
+    if input_dimension == 2:
+        stack_ = _build_stack_from_2d(
+            recipe,
+            input_folder,
+            fov=i_fov,
+            nb_r=nb_r,
+            nb_c=nb_c,
+            nb_z=nb_z)
+    elif input_dimension == 3:
+        stack_ = _build_stack_from_3d(
+            recipe,
+            input_folder,
+            fov=i_fov,
+            nb_r=nb_r,
+            nb_c=nb_c)
+    elif input_dimension == 4:
+        stack_ = _build_stack_from_4d(
+            recipe,
+            input_folder,
+            fov=i_fov,
+            nb_r=nb_r)
+    elif input_dimension == 5:
+        stack_ = _build_stack_from_5d(recipe, input_folder, fov=i_fov)
+    else:
+        raise ValueError("Files do not have the right number of dimensions: "
+                         "{0}. The files we stack should have between 2 and "
+                         "5 dimensions.".format(input_dimension))
+
+    return stack_
+
+
+def _build_stack_from_2d(recipe, input_folder, fov=0, nb_r=1, nb_c=1, nb_z=1):
+    """Load and stack 2-d tensors.
+
+    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'.
+    input_folder : str
+        Path of the folder containing the images.
+    fov : int
+        Index of the fov to build.
+    nb_r : int
+        Number of round file to stack in order to get a 5-d tensor.
+    nb_c : int
+        Number of channel file to stack in order to get a 4-d tensor.
+    nb_z : int
+        Number of z file to stack in order to get a 3-d tensor.
+
+    Returns
+    -------
+    tensor_5d : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+
+    # load and stack successively z, channel then round elements
+    tensors_4d = []
+    for r in range(nb_r):
+
+        # load and stack channel elements (3-d tensors)
+        tensors_3d = []
+        for c in range(nb_c):
+
+            # load and stack z elements (2-d tensors)
+            tensors_2d = []
+            for z in range(nb_z):
+                path = get_path_from_recipe(
+                    recipe,
+                    input_folder,
+                    fov=fov,
+                    r=r,
+                    c=c,
+                    z=z)
+                tensor_2d = stack.read_image(path)
+                tensors_2d.append(tensor_2d)
+
+            # stack 2-d tensors in 3-d
+            tensor_3d = np.stack(tensors_2d, axis=0)
+            tensors_3d.append(tensor_3d)
+
+        # stack 3-d tensors in 4-d
+        tensor_4d = np.stack(tensors_3d, axis=0)
+        tensors_4d.append(tensor_4d)
+
+    # stack 4-d tensors in 5-d
+    tensor_5d = np.stack(tensors_4d, axis=0)
+
+    return tensor_5d
+
+
+def _build_stack_from_3d(recipe, input_folder, fov=0, nb_r=1, nb_c=1):
+    """Load and stack 3-d tensors.
+
+    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'.
+    input_folder : str
+        Path of the folder containing the images.
+    fov : int
+        Index of the fov to build.
+    nb_r : int
+        Number of round file to stack in order to get a 5-d tensor.
+    nb_c : int
+        Number of channel file to stack in order to get a 4-d tensor.
+
+    Returns
+    -------
+    tensor_5d : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+    # load and stack successively channel elements then round elements
+    tensors_4d = []
+    for r in range(nb_r):
+
+        # load and stack channel elements (3-d tensors)
+        tensors_3d = []
+        for c in range(nb_c):
+            path = get_path_from_recipe(
+                recipe,
+                input_folder,
+                fov=fov,
+                r=r,
+                c=c)
+            tensor_3d = stack.read_image(path)
+            tensors_3d.append(tensor_3d)
+
+        # stack 3-d tensors in 4-d
+        tensor_4d = np.stack(tensors_3d, axis=0)
+        tensors_4d.append(tensor_4d)
+
+    # stack 4-d tensors in 5-d
+    tensor_5d = np.stack(tensors_4d, axis=0)
+
+    return tensor_5d
+
+
+def _build_stack_from_4d(recipe, input_folder, fov=0, nb_r=1):
+    """Load and stack 4-d tensors.
+
+    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'.
+    input_folder : str
+        Path of the folder containing the images.
+    fov : int
+        Index of the fov to build.
+    nb_r : int
+        Number of round file to stack in order to get a 5-d tensor.
+
+    Returns
+    -------
+    tensor_5d : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+    # load each file from a new round element and stack them
+    tensors_4d = []
+    for r in range(nb_r):
+        path = get_path_from_recipe(recipe, input_folder, fov=fov, r=r)
+        tensor_4d = stack.read_image(path)
+        tensors_4d.append(tensor_4d)
+
+    # stack 4-d tensors in 5-d
+    tensor_5d = np.stack(tensors_4d, axis=0)
+
+    return tensor_5d
+
+
+def _build_stack_from_5d(recipe, input_folder, fov=0):
+    """Load directly a 5-d tensor.
+
+    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'.
+    input_folder : str
+        Path of the folder containing the images.
+    fov : int
+        Index of the fov to build.
+
+    Returns
+    -------
+    tensor_5d : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+    # the recipe can only contain one file with a 5-d tensor per fov
+    path = get_path_from_recipe(recipe, input_folder, fov=fov)
+    tensor_5d = stack.read_image(path)
+
+    return tensor_5d
+
+
+def _get_input_dimension(recipe, input_folder):
+    """ Load an arbitrary image to get the original dimension of the files.
+
+    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'.
+    input_folder : str
+        Path of the folder containing the images.
+
+    Returns
+    -------
+    nb_dim : int
+        Number of dimensions of the original file.
+
+    """
+    # get a valid path from the recipe
+    path = get_path_from_recipe(recipe, input_folder)
+
+    # load the image and return the number of dimensions
+    image = stack.read_image(path)
+    nb_dim = image.ndim
+
+    return nb_dim
+
+
+def build_stack_no_recipe(paths, input_dimension=None, sanity_check=False):
+    """Build 5-d stack without recipe.
+
+    Parameters
+    ----------
+    paths : List[str]
+        List of the paths to stack.
+    input_dimension : str
+        Number of dimensions of the loaded files. Can speed up the function if
+        provided.
+    sanity_check : bool
+        Check the validity of the loaded tensor. Can slow down the function.
+
+    Returns
+    -------
+    tensor : np.ndarray
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+    # check parameters
+    stack.check_parameter(
+        paths=(str, list),
+        input_dimension=(int, type(None)),
+        sanity_check=bool)
+
+    # build stack from tif files
+    tensor = _load_stack_no_recipe(paths, input_dimension)
+
+    # check the validity of the loaded tensor
+    if sanity_check:
+        stack.check_array(
+            tensor,
+            dtype=[np.uint8, np.uint16, np.uint32,
+                   np.int8, np.int16, np.int32,
+                   np.float16, np.float32, np.float64,
+                   bool],
+            ndim=5,
+            allow_nan=False)
+
+    return tensor
+
+
+def _load_stack_no_recipe(paths, input_dimension=None):
+    """Build a 5-d tensor from the same field of view (fov), without recipe.
+
+    Files with a path listed are stacked together, then empty dimensions are
+    added up to 5.
+
+    Parameters
+    ----------
+    paths : List[str]
+        List of the file to stack.
+    input_dimension : str
+        Number of dimensions of the loaded files.
+
+    Returns
+    -------
+    tensor_5d : np.ndarray, np.uint
+        Tensor with shape (round, channel, z, y, x).
+
+    """
+    # load an image and get the number of dimensions
+    if input_dimension is None:
+        testfile = stack.read_image(paths[0])
+        input_dimension = testfile.ndim
+
+    # get stacks
+    stacks = []
+    for path in paths:
+        s = stack.read_image(path)
+        stacks.append(s)
+
+    # we stack our files according to their initial dimension
+    if input_dimension == 2:
+        tensor_3d = np.stack(stacks, axis=0)
+        tensor_5d = tensor_3d[np.newaxis, np.newaxis, :, :, :]
+    elif input_dimension == 3:
+        tensor_4d = np.stack(stacks, axis=0)
+        tensor_5d = tensor_4d[np.newaxis, :, :, :, :]
+    elif input_dimension == 4:
+        tensor_5d = np.stack(stacks, axis=0)
+    elif input_dimension == 5 and len(stacks) == 1:
+        tensor_5d = stacks[0]
+    else:
+        raise ValueError("Files do not have the right number of dimensions: "
+                         "{0}. The files we stack should have between 2 and "
+                         "5 dimensions.".format(input_dimension))
+
+    return tensor_5d
+
diff --git a/bigfish/multistack/utils.py b/bigfish/multistack/utils.py
new file mode 100644
index 00000000..ebd58854
--- /dev/null
+++ b/bigfish/multistack/utils.py
@@ -0,0 +1,347 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Utility functions for bigfish.multistack subpackage.
+"""
+
+import os
+import re
+import copy
+
+import bigfish.stack as stack
+
+
+# ### Recipe management (sanity checks, fitting) ###
+
+def check_recipe(recipe, data_directory=None):
+    """Check and validate a recipe.
+
+    Checking a recipe consists in validating its filename pattern and the
+    content of the dictionary.
+
+    Parameters
+    ----------
+    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`.
+    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.
+
+    Returns
+    -------
+    _ : bool
+        Assert if the recipe is well formatted.
+
+    """
+    # check parameters
+    stack.check_parameter(
+        recipe=dict,
+        data_directory=(str, type(None)))
+
+    # check the filename pattern
+    if "pattern" not in recipe:
+        raise KeyError("A recipe should have a filename pattern "
+                       "('pattern' keyword).")
+    recipe_pattern = recipe["pattern"]
+    if not isinstance(recipe_pattern, str):
+        raise TypeError("'pattern' should be a string, not a {0}."
+                        .format(type(recipe_pattern)))
+
+    # count the different dimensions to combinate in the recipe (among
+    # 'fov', 'r', 'c' and 'z')
+    dimensions = re.findall("fov|r|c|z", recipe_pattern)
+
+    # each dimension can only appear once in the filename pattern
+    if len(dimensions) != len(set(dimensions)):
+        raise ValueError("The pattern used in recipe is wrong, a dimension "
+                         "appears several times: {0}".format(recipe_pattern))
+
+    # check keys and values of the recipe
+    for key, value in recipe.items():
+        if key not in ['fov', 'r', 'c', 'z', 'ext', 'opt', 'pattern']:
+            raise KeyError("The recipe can only contain the keys 'fov', 'r', "
+                           "'c', 'z', 'ext', 'opt' or 'pattern'. Not '{0}'."
+                           .format(key))
+        if not isinstance(value, (list, str)):
+            raise TypeError("A recipe can only contain lists or strings, "
+                            "not {0}.".format(type(value)))
+
+    # check that requested files exist
+    if data_directory is not None:
+        if not os.path.isdir(data_directory):
+            raise NotADirectoryError("Directory does not exist: {0}"
+                                     .format(data_directory))
+        recipe = fit_recipe(recipe)
+        nb_r, nb_c, nb_z = get_nb_element_per_dimension(recipe)
+        nb_fov = count_nb_fov(recipe)
+        for fov in range(nb_fov):
+            for r in range(nb_r):
+                for c in range(nb_c):
+                    for z in range(nb_z):
+                        path = get_path_from_recipe(
+                            recipe,
+                            data_directory,
+                            fov=fov,
+                            r=r,
+                            c=c,
+                            z=z)
+                        if not os.path.isfile(path):
+                            raise FileNotFoundError("File does not exist: {0}"
+                                                    .format(path))
+
+    return True
+
+
+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
+    also initialized.
+
+    Parameters
+    ----------
+    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`.
+
+    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
+        necessary.
+
+    """
+    # check parameters
+    stack.check_parameter(recipe=dict)
+
+    # initialize recipe
+    new_recipe = copy.deepcopy(recipe)
+
+    # initialize and fit the dimensions 'fov', 'r', 'c' and 'z'
+    for key in ['fov', 'r', 'c', 'z']:
+        if key not in new_recipe:
+            new_recipe[key] = [None]
+        value = new_recipe[key]
+        if isinstance(value, str):
+            new_recipe[key] = [value]
+
+    # initialize the dimensions 'ext', 'opt'
+    for key in ['ext', 'opt']:
+        if key not in new_recipe:
+            new_recipe[key] = ""
+
+    return new_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
+    also initialized.
+
+    Parameters
+    ----------
+    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`.
+
+    Returns
+    -------
+    _ : bool
+        Indicates if the recipe is fitted or not
+
+    """
+    # all keys should be initialized in the new recipe, with a list or a string
+    for key in ['fov', 'r', 'c', 'z']:
+        if key not in recipe or not isinstance(recipe[key], list):
+            return False
+    for key in ['ext', 'opt']:
+        if key not in recipe or not isinstance(recipe[key], str):
+            return False
+    if 'pattern' not in recipe or not isinstance(recipe['pattern'], str):
+        return False
+
+    return True
+
+
+def get_path_from_recipe(recipe, input_folder, fov=0, r=0, c=0, z=0):
+    """Build the path of a file from a recipe and the indices of specific
+    elements.
+
+    Parameters
+    ----------
+    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`.
+    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.
+    r : int
+        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.
+    z : int
+        Index of the `z` element in the recipe to use in the filename.
+
+    Returns
+    -------
+    path : str
+        Path of the file to load.
+
+    """
+    # check parameters
+    stack.check_parameter(
+        recipe=dict,
+        input_folder=str,
+        fov=int,
+        r=int,
+        c=int,
+        z=int)
+
+    # check if the recipe is fitted
+    if not _is_recipe_fitted(recipe):
+        recipe = fit_recipe(recipe)
+
+    # build a map of the elements' indices
+    map_element_index = {"fov": fov, "r": r, "c": c, "z": z}
+
+    # get filename pattern and decompose it
+    recipe_pattern = recipe["pattern"]
+    path_elements = re.findall("fov|r|c|z|ext|opt", recipe_pattern)
+    path_separators = re.split("fov|r|c|z|ext|opt", recipe_pattern)
+
+    # get filename recombining elements of the recipe
+    filename = path_separators[0]  # usually an empty string
+    for (element_name, separator) in zip(path_elements, path_separators[1:]):
+
+        # if we need an element from a list of elements of the same dimension
+        # (eg. to pick a specific channel 'c' among a list of channels)
+        if element_name in map_element_index:
+            element_index = map_element_index[element_name]
+            element = recipe[element_name][element_index]
+
+        # if this element is unique for all the recipe (eg. 'fov')
+        else:
+            element = recipe[element_name]
+
+        # the filename is built ensuring the order of apparition of the
+        # different morphemes and their separators
+        filename += element
+        filename += separator
+
+    # get path
+    path = os.path.join(input_folder, filename)
+
+    return path
+
+
+def get_nb_element_per_dimension(recipe):
+    """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`.
+
+    Returns
+    -------
+    nb_r : int
+        Number of rounds to be stacked.
+    nb_c : int
+        Number of channels to be stacked.
+    nb_z : int
+        Number of z layers to be stacked.
+
+    """
+    # check parameters
+    stack.check_parameter(recipe=dict)
+
+    # check if the recipe is fitted
+    if not _is_recipe_fitted(recipe):
+        recipe = fit_recipe(recipe)
+
+    return len(recipe["r"]), len(recipe["c"]), len(recipe["z"])
+
+
+def count_nb_fov(recipe):
+    """Count the number of different fields of view that can be defined from
+    the recipe.
+
+    Parameters
+    ----------
+    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`.
+
+    Returns
+    -------
+    nb_fov : int
+        Number of different fields of view in the recipe.
+
+    """
+    # check parameters
+    stack.check_parameter(recipe=dict)
+
+    # check if the recipe is fitted
+    if not _is_recipe_fitted(recipe):
+        recipe = fit_recipe(recipe)
+
+    # a good recipe should have a list in the 'fov' key
+    if not isinstance(recipe["fov"], list):
+        raise TypeError("'fov' should be a List or a str, not {0}"
+                        .format(type(recipe["fov"])))
+    else:
+        return len(recipe["fov"])
+
+
+def check_datamap(data_map):
+    """Check and validate a data map.
+
+    Checking a data map consists in validating the recipe-folder pairs.
+
+    Parameters
+    ----------
+    data_map : List[tuple]
+        Map between input directories and recipes.
+
+    Returns
+    -------
+    _ : bool
+        Assert if the data map is well formatted.
+
+    """
+    stack.check_parameter(data_map=list)
+    for pair in data_map:
+        if not isinstance(pair, (tuple, list)):
+            raise TypeError("A data map is a list with tuples or lists. "
+                            "Not {0}".format(type(pair)))
+        if len(pair) != 2:
+            raise ValueError("Elements of a data map are tuples or lists that "
+                             "map a recipe (dict) to an input directory "
+                             "(string). Here {0} elements are given {1}"
+                             .format(len(pair), pair))
+        (recipe, input_folder) = pair
+        if not isinstance(input_folder, str):
+            raise TypeError("A data map map a recipe (dict) to an input "
+                            "directory (string). Not ({0}, {1})"
+                            .format(type(recipe), type(input_folder)))
+        check_recipe(recipe, data_directory=input_folder)
+
+    return True
+
diff --git a/bigfish/plot/__init__.py b/bigfish/plot/__init__.py
index 06accb68..65b5f151 100644
--- a/bigfish/plot/__init__.py
+++ b/bigfish/plot/__init__.py
@@ -18,6 +18,7 @@
 
 from .plot_quality import plot_sharpness
 from .plot_quality import plot_elbow
+from .plot_quality import plot_elbow_colocalized
 
 from .utils import save_plot
 from .utils import get_minmax_values
@@ -36,7 +37,8 @@
 
 _quality = [
     "plot_sharpness",
-    "plot_elbow"]
+    "plot_elbow",
+    "plot_elbow_colocalized"]
 
 _utils = [
     "save_plot",
diff --git a/bigfish/plot/plot_images.py b/bigfish/plot/plot_images.py
index 6a689a37..e9b76d51 100644
--- a/bigfish/plot/plot_images.py
+++ b/bigfish/plot/plot_images.py
@@ -9,6 +9,7 @@
 import warnings
 
 import bigfish.stack as stack
+import bigfish.multistack as multistack
 
 from .utils import save_plot, get_minmax_values, create_colormap
 
@@ -58,20 +59,20 @@ def plot_yx(image, r=0, c=0, z=0, rescale=False, contrast=False,
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3, 4, 5],
-                      dtype=[np.uint8, np.uint16, np.int64,
-                             np.float32, np.float64,
-                             bool])
-    stack.check_parameter(r=int, c=int, z=int,
-                          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, 3, 4, 5],
+        dtype=[np.uint8, np.uint16, np.int64, np.float32, np.float64, bool])
+    stack.check_parameter(
+        r=int, c=int, z=int,
+        rescale=bool,
+        contrast=bool,
+        title=(str, type(None)),
+        framesize=tuple,
+        remove_frame=bool,
+        path_output=(str, type(None)),
+        ext=(str, list),
+        show=bool)
 
     # get the 2-d image
     if image.ndim == 2:
@@ -145,21 +146,23 @@ def plot_images(images, rescale=False, contrast=False, titles=None,
         images = [images]
 
     # check parameters
-    stack.check_parameter(images=list,
-                          rescale=bool,
-                          contrast=bool,
-                          titles=(str, list, type(None)),
-                          framesize=tuple,
-                          remove_frame=bool,
-                          path_output=(str, type(None)),
-                          ext=(str, list),
-                          show=bool)
+    stack.check_parameter(
+        images=list,
+        rescale=bool,
+        contrast=bool,
+        titles=(str, list, type(None)),
+        framesize=tuple,
+        remove_frame=bool,
+        path_output=(str, type(None)),
+        ext=(str, list),
+        show=bool)
     for image in images:
-        stack.check_array(image,
-                          ndim=2,
-                          dtype=[np.uint8, np.uint16, np.int64,
-                                 np.float32, np.float64,
-                                 bool])
+        stack.check_array(
+            image,
+            ndim=2,
+            dtype=[np.uint8, np.uint16, np.int64,
+                   np.float32, np.float64,
+                   bool])
 
     # we plot 3 images by row maximum
     nrow = int(np.ceil(len(images)/3))
@@ -171,15 +174,16 @@ def plot_images(images, rescale=False, contrast=False, titles=None,
             title = titles[0]
         else:
             title = None
-        plot_yx(images[0],
-                rescale=rescale,
-                contrast=contrast,
-                title=title,
-                framesize=framesize,
-                remove_frame=remove_frame,
-                path_output=path_output,
-                ext=ext,
-                show=show)
+        plot_yx(
+            images[0],
+            rescale=rescale,
+            contrast=contrast,
+            title=title,
+            framesize=framesize,
+            remove_frame=remove_frame,
+            path_output=path_output,
+            ext=ext,
+            show=show)
 
         return
 
@@ -227,8 +231,10 @@ def plot_images(images, rescale=False, contrast=False, titles=None,
                     image = stack.rescale(image, channel_to_stretch=0)
                 ax[row, col].imshow(image)
             if titles is not None:
-                ax[row, col].set_title(titles[i],
-                                       fontweight="bold", fontsize=10)
+                ax[row, col].set_title(
+                    titles[i],
+                    fontweight="bold",
+                    fontsize=10)
 
     plt.tight_layout()
     if path_output is not None:
@@ -272,21 +278,22 @@ def plot_segmentation(image, mask, rescale=False, contrast=False, title=None,
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64,
-                             np.float32, np.float64,
-                             bool])
-    stack.check_array(mask,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64, bool])
-    stack.check_parameter(rescale=bool,
-                          contrast=bool,
-                          title=(str, type(None)),
-                          framesize=tuple,
-                          remove_frame=bool,
-                          path_output=(str, type(None)),
-                          ext=(str, list))
+    stack.check_array(
+        image,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, np.float32, np.float64, bool])
+    stack.check_array(
+        mask,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_parameter(
+        rescale=bool,
+        contrast=bool,
+        title=(str, type(None)),
+        framesize=tuple,
+        remove_frame=bool,
+        path_output=(str, type(None)),
+        ext=(str, list))
 
     # plot
     fig, ax = plt.subplots(1, 3, sharex='col', figsize=framesize)
@@ -373,39 +380,43 @@ def plot_segmentation_boundary(image, cell_label=None, nuc_label=None,
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64,
-                             np.float32, np.float64,
-                             bool])
+    stack.check_array(
+        image,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64, np.float32, np.float64, bool])
     if cell_label is not None:
-        stack.check_array(cell_label,
-                          ndim=2,
-                          dtype=[np.uint8, np.uint16, np.int64, bool])
+        stack.check_array(
+            cell_label,
+            ndim=2,
+            dtype=[np.uint8, np.uint16, np.int64, bool])
     if nuc_label is not None:
-        stack.check_array(nuc_label,
-                          ndim=2,
-                          dtype=[np.uint8, np.uint16, np.int64, bool])
-    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(
+            nuc_label,
+            ndim=2,
+            dtype=[np.uint8, np.uint16, np.int64, bool])
+    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)
 
     # get boundaries
     cell_boundaries = None
     nuc_boundaries = None
     if cell_label is not None:
         cell_boundaries = find_boundaries(cell_label, mode='thick')
-        cell_boundaries = np.ma.masked_where(cell_boundaries == 0,
-                                             cell_boundaries)
+        cell_boundaries = np.ma.masked_where(
+            cell_boundaries == 0,
+            cell_boundaries)
     if nuc_label is not None:
         nuc_boundaries = find_boundaries(nuc_label, mode='thick')
-        nuc_boundaries = np.ma.masked_where(nuc_boundaries == 0,
-                                            nuc_boundaries)
+        nuc_boundaries = np.ma.masked_where(
+            nuc_boundaries == 0,
+            nuc_boundaries)
 
     # plot
     if remove_frame:
@@ -473,29 +484,31 @@ def plot_segmentation_diff(image, mask_pred, mask_gt, rescale=False,
 
     """
     # 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])
+    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)
@@ -545,7 +558,6 @@ def plot_segmentation_diff(image, mask_pred, mask_gt, rescale=False,
 
 # ### Detection plot ###
 
-# TODO allow textual annotations
 def plot_detection(image, spots, shape="circle", radius=3, color="red",
                    linewidth=1, fill=False, rescale=False, contrast=False,
                    title=None, framesize=(15, 10), remove_frame=True,
@@ -565,10 +577,10 @@ def plot_detection(image, spots, shape="circle", radius=3, color="red",
         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`.
+    radius : List[int or float], int or float
+        List of yx radii of the detected spots, in pixel. One radius per array
+        in `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
@@ -599,24 +611,25 @@ def plot_detection(image, spots, shape="circle", radius=3, color="red",
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64,
-                             np.float32, np.float64])
-    stack.check_parameter(spots=(list, np.ndarray),
-                          shape=(list, str),
-                          radius=(list, int, float),
-                          color=(list, str),
-                          linewidth=(list, int),
-                          fill=(list, bool),
-                          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])
+    stack.check_parameter(
+        spots=(list, np.ndarray),
+        shape=(list, str),
+        radius=(list, int, float),
+        color=(list, str),
+        linewidth=(list, int),
+        fill=(list, bool),
+        rescale=bool,
+        contrast=bool,
+        title=(str, type(None)),
+        framesize=tuple,
+        remove_frame=bool,
+        path_output=(str, type(None)),
+        ext=(str, list),
+        show=bool)
     if isinstance(spots, list):
         for spots_ in spots:
             stack.check_array(spots_, ndim=2, dtype=[np.int64, np.float64])
@@ -738,22 +751,30 @@ def _define_patch(x, y, shape, radius, color, linewidth, fill):
     """
     # circle
     if shape == "circle":
-        x = plt.Circle((x, y), radius,
-                       color=color,
-                       linewidth=linewidth,
-                       fill=fill)
+        x = plt.Circle(
+            (x, y),
+            radius,
+            color=color,
+            linewidth=linewidth,
+            fill=fill)
     # square
     elif shape == "square":
-        x = plt.Rectangle((x, y), radius, radius,
-                          color=color,
-                          linewidth=linewidth,
-                          fill=fill)
+        x = plt.Rectangle(
+            (x, y),
+            radius,
+            radius,
+            color=color,
+            linewidth=linewidth,
+            fill=fill)
     # polygon
     elif shape == "polygon":
-        x = RegularPolygon((x, y), 5, radius,
-                           color=color,
-                           linewidth=linewidth,
-                           fill=fill)
+        x = RegularPolygon(
+            (x, y),
+            5,
+            radius,
+            color=color,
+            linewidth=linewidth,
+            fill=fill)
     else:
         warnings.warn("shape should take a value among 'circle', 'square' or "
                       "'polygon', but not {0}".format(shape), UserWarning)
@@ -790,18 +811,19 @@ def plot_reference_spot(reference_spot, rescale=False, contrast=False,
 
     """
     # check parameters
-    stack.check_array(reference_spot,
-                      ndim=[2,  3],
-                      dtype=[np.uint8, np.uint16, np.int64,
-                             np.float32, np.float64])
-    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(
+        reference_spot,
+        ndim=[2,  3],
+        dtype=[np.uint8, np.uint16, np.int64, np.float32, np.float64])
+    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)
 
     # project spot in 2-d if necessary
     if reference_spot.ndim == 3:
@@ -821,7 +843,9 @@ def plot_reference_spot(reference_spot, rescale=False, contrast=False,
         plt.imshow(reference_spot)
     else:
         if reference_spot.dtype not in [np.int64, bool]:
-            reference_spot = stack.rescale(reference_spot, channel_to_stretch=0)
+            reference_spot = stack.rescale(
+                reference_spot,
+                channel_to_stretch=0)
         plt.imshow(reference_spot)
     if title is not None and not remove_frame:
         plt.title(title, fontweight="bold", fontsize=25)
@@ -908,25 +932,29 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
     if other_coord is not None:
         stack.check_array(other_coord, ndim=2, dtype=np.int64)
     if image is not None:
-        stack.check_array(image, ndim=2,
-                          dtype=[np.uint8, np.uint16, np.int64,
-                                 np.float32, np.float64])
+        stack.check_array(
+            image,
+            ndim=2,
+            dtype=[np.uint8, np.uint16, np.int64, np.float32, np.float64])
     if cell_mask is not None:
-        stack.check_array(cell_mask,
-                          ndim=2,
-                          dtype=[np.uint8, np.uint16, np.int64, bool])
+        stack.check_array(
+            cell_mask,
+            ndim=2,
+            dtype=[np.uint8, np.uint16, np.int64, bool])
     if nuc_mask is not None:
-        stack.check_array(nuc_mask,
-                          ndim=2,
-                          dtype=[np.uint8, np.uint16, np.int64, bool])
-    stack.check_parameter(ndim=int,
-                          title=(str, type(None)),
-                          remove_frame=bool,
-                          rescale=bool,
-                          contrast=bool,
-                          framesize=tuple,
-                          path_output=(str, type(None)),
-                          ext=(str, list))
+        stack.check_array(
+            nuc_mask,
+            ndim=2,
+            dtype=[np.uint8, np.uint16, np.int64, bool])
+    stack.check_parameter(
+        ndim=int,
+        title=(str, type(None)),
+        remove_frame=bool,
+        rescale=bool,
+        contrast=bool,
+        framesize=tuple,
+        path_output=(str, type(None)),
+        ext=(str, list))
 
     # plot original image and coordinate representation
     if cell_coord is not None and image is not None:
@@ -943,44 +971,68 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
                 image = stack.rescale(image, channel_to_stretch=0)
             ax[0].imshow(image)
         if cell_mask is not None:
-            cell_boundaries = stack.from_surface_to_boundaries(cell_mask)
-            cell_boundaries = np.ma.masked_where(cell_boundaries == 0,
-                                                 cell_boundaries)
+            cell_boundaries = multistack.from_surface_to_boundaries(
+                cell_mask)
+            cell_boundaries = np.ma.masked_where(
+                cell_boundaries == 0,
+                cell_boundaries)
             ax[0].imshow(cell_boundaries, cmap=ListedColormap(['red']))
         if nuc_mask is not None:
-            nuc_boundaries = stack.from_surface_to_boundaries(nuc_mask)
-            nuc_boundaries = np.ma.masked_where(nuc_boundaries == 0,
-                                                nuc_boundaries)
+            nuc_boundaries = multistack.from_surface_to_boundaries(nuc_mask)
+            nuc_boundaries = np.ma.masked_where(
+                nuc_boundaries == 0,
+                nuc_boundaries)
             ax[0].imshow(nuc_boundaries, cmap=ListedColormap(['blue']))
 
         # coordinate image
-        ax[1].plot(cell_coord[:, 1], cell_coord[:, 0],
-                   c="black", linewidth=2)
+        ax[1].plot(cell_coord[:, 1], cell_coord[:, 0], c="black", linewidth=2)
         if nuc_coord is not None:
-            ax[1].plot(nuc_coord[:, 1], nuc_coord[:, 0],
-                       c="steelblue", linewidth=2)
+            ax[1].plot(
+                nuc_coord[:, 1],
+                nuc_coord[:, 0],
+                c="steelblue",
+                linewidth=2)
         if rna_coord is not None:
-            ax[1].scatter(rna_coord[:, ndim - 1], rna_coord[:, ndim - 2],
-                          s=25, c="firebrick", marker=".")
+            ax[1].scatter(
+                rna_coord[:, ndim - 1],
+                rna_coord[:, ndim - 2],
+                s=25,
+                c="firebrick",
+                marker=".")
         if foci_coord is not None:
             for foci in foci_coord:
-                ax[1].text(foci[ndim-1] + 5, foci[ndim-2] - 5, str(foci[ndim]),
-                           color="darkorange", size=20)
+                ax[1].text(
+                    foci[ndim-1] + 5,
+                    foci[ndim-2] - 5,
+                    str(foci[ndim]),
+                    color="darkorange",
+                    size=20)
             # case where we know which rna belong to a foci
             if rna_coord.shape[1] == ndim + 1:
                 foci_indices = foci_coord[:, ndim + 1]
                 mask_rna_in_foci = np.isin(rna_coord[:, ndim], foci_indices)
                 rna_in_foci_coord = rna_coord[mask_rna_in_foci, :].copy()
-                ax[1].scatter(rna_in_foci_coord[:, ndim - 1],
-                              rna_in_foci_coord[:, ndim - 2],
-                              s=25, c="darkorange", marker=".")
+                ax[1].scatter(
+                    rna_in_foci_coord[:, ndim - 1],
+                    rna_in_foci_coord[:, ndim - 2],
+                    s=25,
+                    c="darkorange",
+                    marker=".")
             # case where we only know the foci centroid
             else:
-                ax[1].scatter(foci_coord[:, ndim - 1], foci_coord[:, ndim - 2],
-                              s=40, c="darkorange", marker="o")
+                ax[1].scatter(
+                    foci_coord[:, ndim - 1],
+                    foci_coord[:, ndim - 2],
+                    s=40,
+                    c="darkorange",
+                    marker="o")
         if other_coord is not None:
-            ax[1].scatter(other_coord[:, ndim - 1], other_coord[:, ndim - 2],
-                          s=25, c="forestgreen", marker="D")
+            ax[1].scatter(
+                other_coord[:, ndim - 1],
+                other_coord[:, ndim - 2],
+                s=25,
+                c="forestgreen",
+                marker="D")
 
         # titles and frames
         _, _, min_y, max_y = ax[1].axis()
@@ -992,10 +1044,14 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
             ax[0].axis("off")
             ax[1].axis("off")
         if title is not None:
-            ax[0].set_title("Original image ({0})".format(title),
-                            fontweight="bold", fontsize=10)
-            ax[1].set_title("Coordinate representation ({0})".format(title),
-                            fontweight="bold", fontsize=10)
+            ax[0].set_title(
+                "Original image ({0})".format(title),
+                fontweight="bold",
+                fontsize=10)
+            ax[1].set_title(
+                "Coordinate representation ({0})".format(title),
+                fontweight="bold",
+                fontsize=10)
         plt.tight_layout()
 
         # output
@@ -1018,30 +1074,52 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
         # coordinate image
         plt.plot(cell_coord[:, 1], cell_coord[:, 0], c="black", linewidth=2)
         if nuc_coord is not None:
-            plt.plot(nuc_coord[:, 1], nuc_coord[:, 0],
-                     c="steelblue", linewidth=2)
+            plt.plot(
+                nuc_coord[:, 1],
+                nuc_coord[:, 0],
+                c="steelblue",
+                linewidth=2)
         if rna_coord is not None:
-            plt.scatter(rna_coord[:, ndim - 1], rna_coord[:, ndim - 2],
-                        s=25, c="firebrick", marker=".")
+            plt.scatter(
+                rna_coord[:, ndim - 1],
+                rna_coord[:, ndim - 2],
+                s=25,
+                c="firebrick",
+                marker=".")
         if foci_coord is not None:
             for foci in foci_coord:
-                plt.text(foci[ndim-1] + 5, foci[ndim-2] - 5, str(foci[ndim]),
-                         color="darkorange", size=20)
+                plt.text(
+                    foci[ndim-1] + 5,
+                    foci[ndim-2] - 5,
+                    str(foci[ndim]),
+                    color="darkorange",
+                    size=20)
             # case where we know which rna belong to a foci
             if rna_coord.shape[1] == ndim + 1:
                 foci_indices = foci_coord[:, ndim + 1]
                 mask_rna_in_foci = np.isin(rna_coord[:, ndim], foci_indices)
                 rna_in_foci_coord = rna_coord[mask_rna_in_foci, :].copy()
-                plt.scatter(rna_in_foci_coord[:, ndim - 1],
-                            rna_in_foci_coord[:, ndim - 2],
-                            s=25, c="darkorange", marker=".")
+                plt.scatter(
+                    rna_in_foci_coord[:, ndim - 1],
+                    rna_in_foci_coord[:, ndim - 2],
+                    s=25,
+                    c="darkorange",
+                    marker=".")
             # case where we only know the foci centroid
             else:
-                plt.scatter(foci_coord[:, ndim - 1], foci_coord[:, ndim - 2],
-                            s=40, c="darkorange", marker="o")
+                plt.scatter(
+                    foci_coord[:, ndim - 1],
+                    foci_coord[:, ndim - 2],
+                    s=40,
+                    c="darkorange",
+                    marker="o")
         if other_coord is not None:
-            plt.scatter(other_coord[:, ndim - 1], other_coord[:, ndim - 2],
-                        s=25, c="forestgreen", marker="D")
+            plt.scatter(
+                other_coord[:, ndim - 1],
+                other_coord[:, ndim - 2],
+                s=25,
+                c="forestgreen",
+                marker="D")
 
         # titles and frames
         _, _, min_y, max_y = plt.axis()
@@ -1050,8 +1128,10 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
         plt.margins(0.01, 0.01)
         plt.axis('scaled')
         if title is not None:
-            plt.title("Coordinate representation ({0})".format(title),
-                      fontweight="bold", fontsize=10)
+            plt.title(
+                "Coordinate representation ({0})".format(title),
+                fontweight="bold",
+                fontsize=10)
         if not remove_frame:
             plt.tight_layout()
 
@@ -1066,7 +1146,14 @@ def plot_cell(ndim, cell_coord=None, nuc_coord=None, rna_coord=None,
     # plot original image only
     elif cell_coord is None and image is not None:
         plot_segmentation_boundary(
-            image=image, cell_label=cell_mask, nuc_label=nuc_mask,
-            rescale=rescale, contrast=contrast, title=title,
-            framesize=framesize, remove_frame=remove_frame,
-            path_output=path_output, ext=ext, show=show)
+            image=image,
+            cell_label=cell_mask,
+            nuc_label=nuc_mask,
+            rescale=rescale,
+            contrast=contrast,
+            title=title,
+            framesize=framesize,
+            remove_frame=remove_frame,
+            path_output=path_output,
+            ext=ext,
+            show=show)
diff --git a/bigfish/plot/plot_quality.py b/bigfish/plot/plot_quality.py
index 637adec0..349782f8 100644
--- a/bigfish/plot/plot_quality.py
+++ b/bigfish/plot/plot_quality.py
@@ -8,6 +8,7 @@
 
 import bigfish.stack as stack
 import bigfish.detection as detection
+import bigfish.multistack as multistack
 
 import matplotlib.pyplot as plt
 import numpy as np
@@ -55,21 +56,23 @@ def plot_sharpness(focus_measures, labels=None, title=None, framesize=(5, 5),
         focus_measures = [focus_measures]
 
     # check parameters
-    stack.check_parameter(focus_measures=list,
-                          labels=(list, type(None)),
-                          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)
+    stack.check_parameter(
+        focus_measures=list,
+        labels=(list, type(None)),
+        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)
     length = 0
     for focus_measure in focus_measures:
-        stack.check_array(focus_measure,
-                          ndim=1,
-                          dtype=[np.float32, np.float64])
+        stack.check_array(
+            focus_measure,
+            ndim=1,
+            dtype=[np.float32, np.float64])
         length = max(length, focus_measure.size)
 
     # plot
@@ -98,29 +101,41 @@ def plot_sharpness(focus_measures, labels=None, title=None, framesize=(5, 5),
         plt.close()
 
 
-# ### Elbow plot ###
+# ### Elbow plots ###
 
-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.
+def plot_elbow(images, voxel_size=None, spot_radius=None, log_kernel_size=None,
+               minimum_distance=None, 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 an automated spot detection.
 
     Parameters
     ----------
     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.
+    voxel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions. Not used if 'log_kernel_size' and 'minimum_distance' are
+        provided.
+    spot_radius : int, float, Tuple(int, float), List(int, float) or None
+        Radius of the spot, in nanometer. One value per spatial dimension (zyx
+        or yx dimensions). If it's a scalar, the same radius is applied to
+        every dimensions. Not used if 'log_kernel_size' and 'minimum_distance'
+        are provided.
+    log_kernel_size : int, float, Tuple(int, float), List(int, float) or None
+        Size of the LoG kernel. It equals the standard deviation (in pixels)
+        used for the gaussian kernel (one for each dimension). One value per
+        spatial dimension (zyx or yx dimensions). If it's a scalar, the same
+        standard deviation is applied to every dimensions. If None, we estimate
+        it with the voxel size and spot radius.
+    minimum_distance : int, float, Tuple(int, float), List(int, float) or None
+        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. If None, we estimate it with the voxel size and spot
+        radius.
     title : str or None
         Title of the plot.
     framesize : tuple
@@ -141,37 +156,156 @@ def plot_elbow(images, voxel_size_z, voxel_size_yx, psf_z, psf_yx, title=None,
 
     """
     # 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)
+    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 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)
+        images,
+        voxel_size=voxel_size,
+        spot_radius=spot_radius,
+        log_kernel_size=log_kernel_size,
+        minimum_distance=minimum_distance)
 
     # plot
     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")
+        plt.scatter(
+            threshold,
+            count_spots[i_threshold],
+            marker="D",
+            c="#d7191c",
+            s=60,
+            label="Selected threshold")
 
     # 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)
+    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()
+
+
+def plot_elbow_colocalized(spots_1, spots_2, voxel_size, threshold_max=None,
+                           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 an automated colocalized spot
+    detection.
+
+    Parameters
+    ----------
+    spots_1 : np.ndarray
+        Coordinates of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+    spots_2 : np.ndarray
+        Coordinates of the spots with shape (nb_spots, 3) or (nb_spots, 2).
+    voxel_size : int, float, Tuple(int, float), or List(int, float)
+        Size of a voxel, in nanometer. One value per spatial dimension (zyx or
+        yx dimensions). If it's a scalar, the same value is applied to every
+        dimensions.
+    threshold_max : int, float or None
+        Maximum threshold value to consider.
+    title : str or None
+        Title of the plot.
+    framesize : tuple
+        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.
+    show : bool
+        Show the figure or not.
+
+    """
+    # check parameters
+    stack.check_parameter(
+        threshold_max=(int, float, type(None)),
+        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 thresholds and colocalized spots count to plot the elbow curve
+    (thresholds, count_colocalized,
+     threshold) = multistack.get_elbow_value_colocalized(
+        spots_1=spots_1,
+        spots_2=spots_2,
+        voxel_size=voxel_size)
+
+    # plot
+    plt.figure(figsize=framesize)
+    plt.hlines(
+        len(spots_1),
+        thresholds[0],
+        thresholds[-1],
+        colors="forestgreen",
+        linestyles="--",
+        label="Spots 1")
+    plt.hlines(
+        len(spots_2),
+        thresholds[0],
+        thresholds[-1],
+        colors="steelblue",
+        linestyles="--",
+        label="Spots 2")
+    plt.plot(
+        thresholds,
+        count_colocalized,
+        color="firebrick",
+        label="Colocalized spots")
+    if threshold is not None:
+        i_threshold = np.argmax(thresholds == threshold)
+        plt.scatter(
+            threshold,
+            count_colocalized[i_threshold],
+            marker="D",
+            c="#d7191c",
+            s=60,
+            label="Selected threshold")
+
+    #  define xlim
+    if threshold_max is not None:
+        plt.xlim((0, threshold_max))
+
+    # 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",
+        fontweight="bold",
+        fontsize=size_axes)
     if threshold is not None:
         plt.legend(prop={'size': size_legend})
     plt.tight_layout()
diff --git a/bigfish/plot/utils.py b/bigfish/plot/utils.py
index 2c7a2042..50ecaa9a 100644
--- a/bigfish/plot/utils.py
+++ b/bigfish/plot/utils.py
@@ -87,8 +87,6 @@ def create_colormap():
         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))
diff --git a/bigfish/segmentation/__init__.py b/bigfish/segmentation/__init__.py
index f91f0309..397b9d1d 100644
--- a/bigfish/segmentation/__init__.py
+++ b/bigfish/segmentation/__init__.py
@@ -23,16 +23,13 @@
 from .postprocess import merge_labels
 from .postprocess import clean_segmentation
 from .postprocess import remove_disjoint
-from .postprocess import match_nuc_cell
 
 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
+
 
 _cell = [
     "unet_distance_edge_double",
@@ -52,17 +49,13 @@
     "label_instances",
     "merge_labels",
     "clean_segmentation",
-    "remove_disjoint",
-    "match_nuc_cell"]
+    "remove_disjoint"]
 
 _utils = [
     "thresholding",
     "compute_mean_diameter",
     "compute_mean_convexity_ratio",
     "compute_surface_ratio",
-    "count_instances",
-    "resize_image",
-    "get_marge_padding",
-    "compute_image_standardization"]
+    "count_instances"]
 
 __all__ = _cell + _nuc + _postprocess + _utils
diff --git a/bigfish/segmentation/cell_segmentation.py b/bigfish/segmentation/cell_segmentation.py
index 57266402..2ca10cf9 100644
--- a/bigfish/segmentation/cell_segmentation.py
+++ b/bigfish/segmentation/cell_segmentation.py
@@ -7,10 +7,8 @@
 """
 
 import bigfish.stack as stack
+
 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
 
@@ -75,8 +73,9 @@ def apply_unet_distance_double(model, nuc, cell, nuc_label, target_size=None,
 
     """
     # check parameters
-    stack.check_parameter(target_size=(int, type(None)),
-                          test_time_augmentation=bool)
+    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)
@@ -101,21 +100,21 @@ def apply_unet_distance_double(model, nuc, cell, nuc_label, target_size=None,
         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_to_process = stack.resize_image(nuc, new_shape, "bilinear")
+        cell_to_process = stack.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)
+    marge_padding = stack.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 = stack.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 = stack.compute_image_standardization(cell_to_process)
     cell_to_process = cell_to_process.astype(np.float32)
 
     # augment images
@@ -154,14 +153,14 @@ def apply_unet_distance_double(model, nuc, cell, nuc_label, target_size=None,
         # from the image augmentation
         if target_size is not None:
             if i in [0, 1, 2, 6]:
-                prediction_cell = resize_image(
+                prediction_cell = stack.resize_image(
                     prediction_cell, (height, width), "bilinear")
-                prediction_distance = resize_image(
+                prediction_distance = stack.resize_image(
                     prediction_distance, (height, width), "bilinear")
             else:
-                prediction_cell = resize_image(
+                prediction_cell = stack.resize_image(
                     prediction_cell, (width, height), "bilinear")
-                prediction_distance = resize_image(
+                prediction_distance = stack.resize_image(
                     prediction_distance, (width, height), "bilinear")
 
         # store predictions
@@ -186,8 +185,9 @@ def apply_unet_distance_double(model, nuc, cell, nuc_label, target_size=None,
     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)
+    mean_prediction_distance = stack.cast_img_uint16(
+        mean_prediction_distance,
+        catch_warning=True)
 
     # postprocess predictions
     _, cell_label_pred = from_distance_to_instances(
@@ -229,8 +229,9 @@ def from_distance_to_instances(label_x_nuc, label_2_cell, label_distance,
 
     """
     # check parameters
-    stack.check_parameter(nuc_3_classes=bool,
-                          compute_nuc_label=bool)
+    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])
@@ -309,9 +310,10 @@ def cell_watershed(image, nuc_label, threshold, alpha=0.8):
         image_2d = image
     cell_mask = thresholding(image_2d, threshold)
     cell_mask[nuc_label > 0] = True
-    cell_mask = clean_segmentation(cell_mask,
-                                   small_object_size=5000,
-                                   fill_holes=True)
+    cell_mask = clean_segmentation(
+        cell_mask,
+        small_object_size=5000,
+        fill_holes=True)
 
     # segment cells
     cell_label = apply_watershed(relief, nuc_label, cell_mask)
@@ -347,9 +349,10 @@ def get_watershed_relief(image, nuc_label, alpha):
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=[2, 3],
-                      dtype=[np.uint8, np.uint16])
+    stack.check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16])
     stack.check_array(nuc_label, ndim=2, dtype=np.int64)
     stack.check_parameter(alpha=(int, float))
 
@@ -364,22 +367,26 @@ def get_watershed_relief(image, nuc_label, alpha):
         # build watershed relief
         watershed_relief = image.max() - image
         watershed_relief[nuc_label > 0] = 0
-        watershed_relief = np.true_divide(watershed_relief,
-                                          watershed_relief.max(),
-                                          dtype=np.float64)
-        watershed_relief = stack.cast_img_uint16(watershed_relief,
-                                                 catch_warning=True)
+        watershed_relief = np.true_divide(
+            watershed_relief,
+            watershed_relief.max(),
+            dtype=np.float64)
+        watershed_relief = stack.cast_img_uint16(
+            watershed_relief,
+            catch_warning=True)
 
     # use distance from the nuclei
     elif alpha == 0:
         # build watershed relief
         nuc_mask = nuc_label > 0
         watershed_relief = ndi.distance_transform_edt(~nuc_mask)
-        watershed_relief = np.true_divide(watershed_relief,
-                                          watershed_relief.max(),
-                                          dtype=np.float64)
-        watershed_relief = stack.cast_img_uint16(watershed_relief,
-                                                 catch_warning=True)
+        watershed_relief = np.true_divide(
+            watershed_relief,
+            watershed_relief.max(),
+            dtype=np.float64)
+        watershed_relief = stack.cast_img_uint16(
+            watershed_relief,
+            catch_warning=True)
 
     # use a combination of both previous methods
     elif 0 < alpha < 1:
@@ -392,17 +399,20 @@ def get_watershed_relief(image, nuc_label, alpha):
         # build watershed relief
         relief_pixel = image.max() - image
         relief_pixel[nuc_label > 0] = 0
-        relief_pixel = np.true_divide(relief_pixel,
-                                      relief_pixel.max(),
-                                      dtype=np.float64)
+        relief_pixel = np.true_divide(
+            relief_pixel,
+            relief_pixel.max(),
+            dtype=np.float64)
         nuc_mask = nuc_label > 0
         relief_distance = ndi.distance_transform_edt(~nuc_mask)
-        relief_distance = np.true_divide(relief_distance,
-                                         relief_distance.max(),
-                                         dtype=np.float64)
+        relief_distance = np.true_divide(
+            relief_distance,
+            relief_distance.max(),
+            dtype=np.float64)
         watershed_relief = alpha * relief_pixel + (1 - alpha) * relief_distance
-        watershed_relief = stack.cast_img_uint16(watershed_relief,
-                                                 catch_warning=True)
+        watershed_relief = stack.cast_img_uint16(
+            watershed_relief,
+            catch_warning=True)
 
     else:
         raise ValueError("Parameter 'alpha' is wrong. It must be comprised "
@@ -440,9 +450,10 @@ def apply_watershed(watershed_relief, nuc_label, cell_mask):
 
     """
     # check parameters
-    stack.check_array(watershed_relief,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(
+        watershed_relief,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64])
     stack.check_array(nuc_label, ndim=2, dtype=np.int64)
     stack.check_array(cell_mask, ndim=2, dtype=bool)
 
diff --git a/bigfish/segmentation/nuc_segmentation.py b/bigfish/segmentation/nuc_segmentation.py
index c8dda8b8..79d0c903 100644
--- a/bigfish/segmentation/nuc_segmentation.py
+++ b/bigfish/segmentation/nuc_segmentation.py
@@ -9,9 +9,7 @@
 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
 
@@ -68,8 +66,9 @@ def apply_unet_3_classes(model, image, target_size=None,
 
     """
     # check parameters
-    stack.check_parameter(target_size=(int, type(None)),
-                          test_time_augmentation=bool)
+    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
@@ -90,16 +89,16 @@ def apply_unet_3_classes(model, image, target_size=None,
         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")
+        image_to_process = stack.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)
+    marge_padding = stack.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 = stack.compute_image_standardization(image_to_process)
     image_to_process = image_to_process.astype(np.float32)
 
     # augment images
@@ -131,10 +130,10 @@ def apply_unet_3_classes(model, image, target_size=None,
         # from the image augmentation
         if target_size is not None:
             if i in [0, 1, 2, 6]:
-                prediction = resize_image(
+                prediction = stack.resize_image(
                     prediction, (height, width), "bilinear")
             else:
-                prediction = resize_image(
+                prediction = stack.resize_image(
                     prediction, (width, height), "bilinear")
 
         # store predictions
@@ -251,9 +250,10 @@ def remove_segmented_nuc(image, nuc_mask, size_nuclei=2000):
 
     # build the binary mask for the missing nuclei
     missing_mask = image_filtered > 0
-    missing_mask = clean_segmentation(missing_mask,
-                                      small_object_size=size_nuclei,
-                                      fill_holes=True)
+    missing_mask = clean_segmentation(
+        missing_mask,
+        small_object_size=size_nuclei,
+        fill_holes=True)
     missing_mask = stack.dilation_filter(missing_mask, "disk", 20)
 
     # TODO improve the thresholds
diff --git a/bigfish/segmentation/postprocess.py b/bigfish/segmentation/postprocess.py
index 02e95f72..60502574 100644
--- a/bigfish/segmentation/postprocess.py
+++ b/bigfish/segmentation/postprocess.py
@@ -26,7 +26,7 @@ def label_instances(image_binary):
     Parameters
     ----------
     image_binary : np.ndarray, bool
-        Binary segmented image with shape (y, x).
+        Binary segmented image with shape (z, y, x) or (y, x).
 
     Returns
     -------
@@ -35,7 +35,7 @@ def label_instances(image_binary):
 
     """
     # check parameters
-    stack.check_array(image_binary, ndim=2, dtype=bool)
+    stack.check_array(image_binary, ndim=[2, 3], dtype=bool)
 
     # label instances
     image_label = label(image_binary)
@@ -51,19 +51,19 @@ def merge_labels(image_label_1, image_label_2):
     Parameters
     ----------
     image_label_1 : np.ndarray, np.int64
-        Labelled image with shape (y, x).
+        Labelled image with shape (z, y, x) or (y, x).
     image_label_2 : np.ndarray, np.int64
-        Labelled image with shape (y, x).
+        Labelled image with shape (z, y, x) or (y, x).
 
     Returns
     -------
     image_label : np.ndarray, np.int64
-        Labelled image with shape (y, x).
+        Labelled image with shape (z, y, x) or (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)
+    stack.check_array(image_label_1, ndim=[2, 3], dtype=np.int64)
+    stack.check_array(image_label_2, ndim=[2, 3], dtype=np.int64)
 
     # count number of instances
     nb_instances_1 = image_label_1.max()
@@ -82,8 +82,8 @@ def merge_labels(image_label_1, image_label_2):
     return image_label
 
 
-# ### Basic segmentation ###
-
+# ### Clean segmentation ###
+# TODO make it available for 3D images
 def clean_segmentation(image, small_object_size=None, fill_holes=False,
                        smoothness=None, delimit_instance=False):
     """Clean segmentation results (binary masks or integer labels).
@@ -111,10 +111,11 @@ def clean_segmentation(image, small_object_size=None, fill_holes=False,
     """
     # 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)
+    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()
@@ -265,19 +266,26 @@ def remove_disjoint(image):
 
     Parameters
     ----------
-    image : np.ndarray, np.int or np.uint
-        Labelled image with shape (y, x).
+    image : np.ndarray, np.int, np.uint or bool
+        Labelled image with shape (z, y, x) or (y, x).
 
     Returns
     -------
     image_cleaned : np.ndarray, np.int or np.uint
-        Cleaned image with shape (y, x).
+        Cleaned image with shape (z, y, x) or (y, x).
 
     """
     # check parameters
-    stack.check_array(image,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.int64, bool])
+
+    # handle boolean array
+    cast_to_bool = False
+    if image.dtype == bool:
+        cast_to_bool = bool
+        image = image.astype(np.uint8)
 
     # initialize cleaned labels
     image_cleaned = np.zeros_like(image)
@@ -312,119 +320,7 @@ def remove_disjoint(image):
         # 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))
+    if cast_to_bool:
+        image_cleaned = image_cleaned.astype(bool)
 
-    return value
+    return image_cleaned
diff --git a/bigfish/segmentation/utils.py b/bigfish/segmentation/utils.py
index bf3e4482..4e768a3c 100644
--- a/bigfish/segmentation/utils.py
+++ b/bigfish/segmentation/utils.py
@@ -11,7 +11,6 @@
 import numpy as np
 
 from skimage.measure import regionprops
-from skimage.transform import resize
 
 
 # TODO make functions compatible with different type of integers
@@ -66,9 +65,10 @@ def compute_mean_diameter(image_label):
 
     """
     # check parameters
-    stack.check_array(image_label,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(
+        image_label,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64])
 
     # compute properties of the segmented instances
     props = regionprops(image_label)
@@ -104,9 +104,10 @@ def compute_mean_convexity_ratio(image_label):
 
     """
     # check parameters
-    stack.check_array(image_label,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(
+        image_label,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64])
 
     # compute properties of the segmented instances
     props = regionprops(image_label)
@@ -141,9 +142,10 @@ def compute_surface_ratio(image_label):
 
     """
     # check parameters
-    stack.check_array(image_label,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(
+        image_label,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64])
 
     # compute surface ratio
     surface_instances = image_label > 0
@@ -168,9 +170,10 @@ def count_instances(image_label):
 
     """
     # check parameters
-    stack.check_array(image_label,
-                      ndim=2,
-                      dtype=[np.uint8, np.uint16, np.int64])
+    stack.check_array(
+        image_label,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.int64])
 
     indices = set(image_label.ravel())
     if 0 in indices:
@@ -179,116 +182,3 @@ def count_instances(image_label):
         nb_instances = len(indices)
 
     return nb_instances
-
-
-# ### 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
-        Image to resize.
-    output_shape : Tuple[int]
-        Shape of the resized image.
-    method : str
-        Interpolation method to use.
-
-    Returns
-    -------
-    image_resized : np.ndarray
-        Resized image.
-
-    """
-    # 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:
-        raise ValueError("Method {0} is not available. Choose between "
-                         "'bilinear' or 'nearest' instead.".format(method))
-
-    # cast output dtype
-    image_resized = image_resized.astype(image.dtype)
-
-    return image_resized
-
-
-def get_marge_padding(height, width, x):
-    """Pad image to make its shape a multiple of `x`.
-
-    Parameters
-    ----------
-    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
-    -------
-    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_parameter(height=int, width=int, x=int)
-
-    # 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
-
-
-def compute_image_standardization(image):
-    """Normalize image by computing its z score.
-
-    Parameters
-    ----------
-    image : np.ndarray
-        Image to normalize with shape (y, x).
-
-    Returns
-    -------
-    normalized_image : np.ndarray
-        Normalized image with shape (y, x).
-
-    """
-    # check parameters
-    stack.check_array(image, ndim=2, dtype=[np.uint8, np.uint16, np.float32])
-
-    # 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)))
-
-    # compute mean and standard deviation
-    m = np.mean(image)
-    adjusted_stddev = max(np.std(image), 1.0 / np.sqrt(image.size))
-
-    # normalize image
-    normalized_image = (image - m) / adjusted_stddev
-
-    return normalized_image
diff --git a/bigfish/stack/__init__.py b/bigfish/stack/__init__.py
index 06e1f5e4..9950ba5b 100644
--- a/bigfish/stack/__init__.py
+++ b/bigfish/stack/__init__.py
@@ -11,8 +11,6 @@
 from .utils import check_df
 from .utils import check_parameter
 from .utils import check_range_value
-from .utils import check_recipe
-from .utils import check_datamap
 from .utils import get_margin_value
 from .utils import get_eps_float32
 from .utils import load_and_save_url
@@ -21,8 +19,6 @@
 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
@@ -36,14 +32,14 @@
 from .io import save_cell_extracted
 from .io import save_data_to_csv
 
-from .preprocess import build_stacks
-from .preprocess import build_stack
-from .preprocess import build_stack_no_recipe
 from .preprocess import rescale
 from .preprocess import cast_img_uint8
 from .preprocess import cast_img_uint16
 from .preprocess import cast_img_float32
 from .preprocess import cast_img_float64
+from .preprocess import resize_image
+from .preprocess import get_marge_padding
+from .preprocess import compute_image_standardization
 
 from .filter import mean_filter
 from .filter import median_filter
@@ -63,22 +59,6 @@
 from .projection import in_focus_selection
 from .projection import get_in_focus_indices
 
-from .illumination import compute_illumination_surface
-from .illumination import correct_illumination_surface
-
-from .postprocess import identify_objects_in_region
-from .postprocess import remove_transcription_site
-from .postprocess import extract_cell
-from .postprocess import extract_spots_from_frame
-from .postprocess import summarize_extraction_results
-from .postprocess import center_mask_coord
-from .postprocess import from_boundaries_to_surface
-from .postprocess import from_surface_to_boundaries
-from .postprocess import from_binary_to_coord
-from .postprocess import complete_coord_boundaries
-from .postprocess import from_coord_to_frame
-from .postprocess import from_coord_to_surface
-
 from .augmentation import augment_2d
 from .augmentation import augment_2d_function
 from .augmentation import augment_8_times
@@ -90,8 +70,6 @@
 _utils = [
     "check_array",
     "check_df",
-    "check_recipe",
-    "check_datamap",
     "check_parameter",
     "check_range_value",
     "get_margin_value",
@@ -101,9 +79,7 @@
     "compute_hash",
     "check_input_data",
     "moving_average",
-    "centered_moving_average",
-    "get_sigma",
-    "get_radius"]
+    "centered_moving_average"]
 
 _io = [
     "read_image",
@@ -119,14 +95,14 @@
     "save_data_to_csv"]
 
 _preprocess = [
-    "build_stacks",
-    "build_stack",
-    "build_stack_no_recipe",
     "rescale",
     "cast_img_uint8",
     "cast_img_uint16",
     "cast_img_float32",
-    "cast_img_float64"]
+    "cast_img_float64",
+    "resize_image",
+    "get_marge_padding",
+    "compute_image_standardization"]
 
 _filter = [
     "log_filter",
@@ -148,24 +124,6 @@
     "get_in_focus_indices",
     "focus_projection"]
 
-_illumination = [
-    "compute_illumination_surface",
-    "correct_illumination_surface"]
-
-_postprocess = [
-    "identify_objects_in_region",
-    "remove_transcription_site",
-    "extract_cell",
-    "extract_spots_from_frame",
-    "summarize_extraction_results",
-    "center_mask_coord",
-    "from_boundaries_to_surface",
-    "from_surface_to_boundaries",
-    "from_binary_to_coord",
-    "complete_coord_boundaries",
-    "from_coord_to_frame",
-    "from_coord_to_surface"]
-
 _augmentation = [
     "augment_2d",
     "augment_2d_function",
@@ -176,5 +134,5 @@
     "compute_focus"]
 
 
-__all__ = (_utils + _io + _preprocess + _postprocess + _filter + _projection +
-           _illumination + _augmentation + _quality)
+__all__ = (_utils + _io + _preprocess + _filter + _projection + _augmentation
+           + _quality)
diff --git a/bigfish/stack/augmentation.py b/bigfish/stack/augmentation.py
index 047e4e7a..97938fa8 100644
--- a/bigfish/stack/augmentation.py
+++ b/bigfish/stack/augmentation.py
@@ -30,11 +30,7 @@ def augment_2d(image):
     check_array(image, ndim=[2, 3])
 
     # 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)
+    random_operation = augment_2d_function()
 
     # augment the image
     image_augmented = random_operation(image)
diff --git a/bigfish/stack/filter.py b/bigfish/stack/filter.py
index 516fd540..a6bc5d59 100644
--- a/bigfish/stack/filter.py
+++ b/bigfish/stack/filter.py
@@ -91,16 +91,19 @@ def mean_filter(image, kernel_shape, kernel_size):
 
     """
     # check parameters
-    check_array(image,
-                ndim=2,
-                dtype=[np.float32, np.float64, np.uint8, np.uint16])
-    check_parameter(kernel_shape=str,
-                    kernel_size=(int, tuple, list))
+    check_array(
+        image,
+        ndim=2,
+        dtype=[np.float32, np.float64, np.uint8, np.uint16])
+    check_parameter(
+        kernel_shape=str,
+        kernel_size=(int, tuple, list))
 
     # build kernel
-    kernel = _define_kernel(shape=kernel_shape,
-                            size=kernel_size,
-                            dtype=np.float64)
+    kernel = _define_kernel(
+        shape=kernel_shape,
+        size=kernel_size,
+        dtype=np.float64)
     n = kernel.sum()
     kernel /= n
 
@@ -131,16 +134,19 @@ def median_filter(image, kernel_shape, kernel_size):
 
     """
     # check parameters
-    check_array(image,
-                ndim=2,
-                dtype=[np.uint8, np.uint16])
-    check_parameter(kernel_shape=str,
-                    kernel_size=(int, tuple, list))
+    check_array(
+        image,
+        ndim=2,
+        dtype=[np.uint8, np.uint16])
+    check_parameter(
+        kernel_shape=str,
+        kernel_size=(int, tuple, list))
 
     # get kernel
-    kernel = _define_kernel(shape=kernel_shape,
-                            size=kernel_size,
-                            dtype=image.dtype)
+    kernel = _define_kernel(
+        shape=kernel_shape,
+        size=kernel_size,
+        dtype=image.dtype)
 
     # apply filter
     image_filtered = rank.median(image, kernel)
@@ -169,16 +175,19 @@ def maximum_filter(image, kernel_shape, kernel_size):
 
     """
     # check parameters
-    check_array(image,
-                ndim=2,
-                dtype=[np.uint8, np.uint16])
-    check_parameter(kernel_shape=str,
-                    kernel_size=(int, tuple, list))
+    check_array(
+        image,
+        ndim=2,
+        dtype=[np.uint8, np.uint16])
+    check_parameter(
+        kernel_shape=str,
+        kernel_size=(int, tuple, list))
 
     # get kernel
-    kernel = _define_kernel(shape=kernel_shape,
-                            size=kernel_size,
-                            dtype=image.dtype)
+    kernel = _define_kernel(
+        shape=kernel_shape,
+        size=kernel_size,
+        dtype=image.dtype)
 
     # apply filter
     image_filtered = rank.maximum(image, kernel)
@@ -207,16 +216,19 @@ def minimum_filter(image, kernel_shape, kernel_size):
 
     """
     # check parameters
-    check_array(image,
-                ndim=2,
-                dtype=[np.uint8, np.uint16])
-    check_parameter(kernel_shape=str,
-                    kernel_size=(int, tuple, list))
+    check_array(
+        image,
+        ndim=2,
+        dtype=[np.uint8, np.uint16])
+    check_parameter(
+        kernel_shape=str,
+        kernel_size=(int, tuple, list))
 
     # get kernel
-    kernel = _define_kernel(shape=kernel_shape,
-                            size=kernel_size,
-                            dtype=image.dtype)
+    kernel = _define_kernel(
+        shape=kernel_shape,
+        size=kernel_size,
+        dtype=image.dtype)
 
     # apply filter
     image_filtered = rank.minimum(image, kernel)
@@ -236,10 +248,10 @@ def log_filter(image, sigma):
     ----------
     image : np.ndarray
         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. Can be computed
-        with :func:`bigfish.stack.get_sigma`.
+    sigma : int, float, Tuple(float, int) or List(float, int)
+        Standard deviation used for the gaussian kernel (one for each
+        dimension). If it's a scalar, the same standard deviation is applied
+        to every dimensions.
 
     Returns
     -------
@@ -248,9 +260,10 @@ def log_filter(image, sigma):
 
     """
     # check parameters
-    check_array(image,
-                ndim=[2, 3],
-                dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
     check_parameter(sigma=(float, int, tuple, list))
 
     # we cast the data in np.float to allow negative values
@@ -264,8 +277,8 @@ def log_filter(image, sigma):
     # check sigma
     if isinstance(sigma, (tuple, list)):
         if len(sigma) != image.ndim:
-            raise ValueError("'sigma' must be a scalar or a sequence with the "
-                             "same length as 'image.ndim'.")
+            raise ValueError("'sigma' must be a scalar or a sequence with {0} "
+                             "elements.".format(image.ndim))
 
     # we apply LoG filter
     image_filtered = gaussian_laplace(image_float, sigma=sigma)
@@ -292,10 +305,10 @@ def gaussian_filter(image, sigma, allow_negative=False):
     ----------
     image : np.ndarray
         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. Can be computed
-        with :func:`bigfish.stack.get_sigma`.
+    sigma : int, float, Tuple(float, int) or List(float, int)
+        Standard deviation used for the gaussian kernel (one for each
+        dimension). If it's a scalar, the same standard deviation is applied
+        to every dimensions.
     allow_negative : bool
         Allow negative values after the filtering or clip them to 0. Not
         compatible with unsigned integer images.
@@ -307,15 +320,23 @@ def gaussian_filter(image, sigma, allow_negative=False):
 
     """
     # check parameters
-    check_array(image,
-                ndim=[2, 3],
-                dtype=[np.uint8, np.uint16, np.float32, np.float64])
-    check_parameter(sigma=(float, int, tuple, list),
-                    allow_negative=bool)
-
+    check_array(
+        image,
+        ndim=[2, 3],
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    check_parameter(
+        sigma=(float, int, tuple, list),
+        allow_negative=bool)
+
+    # check parameters consistency
     if image.dtype in [np.uint8, np.uint16] and allow_negative:
         raise ValueError("Negative values are impossible with unsigned "
                          "integer image.")
+    # check sigma
+    if isinstance(sigma, (tuple, list)):
+        if len(sigma) != image.ndim:
+            raise ValueError("'sigma' must be a scalar or a sequence with {0} "
+                             "elements.".format(image.ndim))
 
     # we cast the data in np.float to allow negative values
     if image.dtype == np.uint8:
@@ -364,16 +385,18 @@ def remove_background_mean(image, kernel_shape="disk", kernel_size=200):
 
     """
     # compute background noise with a large mean filter
-    background = mean_filter(image,
-                             kernel_shape=kernel_shape,
-                             kernel_size=kernel_size)
+    background = mean_filter(
+        image,
+        kernel_shape=kernel_shape,
+        kernel_size=kernel_size)
 
     # subtract the background from the original image, clipping negative
     # values to 0
     mask = image > background
-    image_without_back = np.subtract(image, background,
-                                     out=np.zeros_like(image),
-                                     where=mask)
+    image_without_back = np.subtract(
+        image, background,
+        out=np.zeros_like(image),
+        where=mask)
 
     return image_without_back
 
@@ -386,10 +409,10 @@ def remove_background_gaussian(image, sigma):
     ----------
     image : np.ndarray
         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. Can be computed
-        with :func:`bigfish.stack.get_sigma`.
+    sigma : int, float, Tuple(float, int) or List(float, int)
+        Standard deviation used for the gaussian kernel (one for each
+        dimension). If it's a scalar, the same standard deviation is applied
+        to every dimensions.
 
     Returns
     -------
@@ -398,15 +421,15 @@ def remove_background_gaussian(image, sigma):
 
     """
     # apply a gaussian filter
-    image_filtered = gaussian_filter(image, sigma,
-                                     allow_negative=False)
+    image_filtered = gaussian_filter(image, sigma, allow_negative=False)
 
     # subtract the gaussian filter
     out = np.zeros_like(image)
-    image_no_background = np.subtract(image, image_filtered,
-                                      out=out,
-                                      where=(image > image_filtered),
-                                      dtype=image.dtype)
+    image_no_background = np.subtract(
+        image, image_filtered,
+        out=out,
+        where=(image > image_filtered),
+        dtype=image.dtype)
 
     return image_no_background
 
@@ -434,19 +457,22 @@ def dilation_filter(image, kernel_shape=None, kernel_size=None):
 
     """
     # check parameters
-    check_array(image,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.float32, np.float64, bool])
-    check_parameter(kernel_shape=(str, type(None)),
-                    kernel_size=(int, tuple, list, type(None)))
+    check_array(
+        image,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.float32, np.float64, bool])
+    check_parameter(
+        kernel_shape=(str, type(None)),
+        kernel_size=(int, tuple, list, type(None)))
 
     # get kernel
     if kernel_shape is None or kernel_size is None:
         kernel = None
     else:
-        kernel = _define_kernel(shape=kernel_shape,
-                                size=kernel_size,
-                                dtype=image.dtype)
+        kernel = _define_kernel(
+            shape=kernel_shape,
+            size=kernel_size,
+            dtype=image.dtype)
 
     # apply filter
     if image.dtype == bool:
@@ -480,19 +506,22 @@ def erosion_filter(image, kernel_shape=None, kernel_size=None):
 
     """
     # check parameters
-    check_array(image,
-                ndim=2,
-                dtype=[np.uint8, np.uint16, np.float32, np.float64, bool])
-    check_parameter(kernel_shape=(str, type(None)),
-                    kernel_size=(int, tuple, list, type(None)))
+    check_array(
+        image,
+        ndim=2,
+        dtype=[np.uint8, np.uint16, np.float32, np.float64, bool])
+    check_parameter(
+        kernel_shape=(str, type(None)),
+        kernel_size=(int, tuple, list, type(None)))
 
     # get kernel
     if kernel_shape is None or kernel_size is None:
         kernel = None
     else:
-        kernel = _define_kernel(shape=kernel_shape,
-                                size=kernel_size,
-                                dtype=image.dtype)
+        kernel = _define_kernel(
+            shape=kernel_shape,
+            size=kernel_size,
+            dtype=image.dtype)
 
     # apply filter
     if image.dtype == bool:
diff --git a/bigfish/stack/illumination.py b/bigfish/stack/illumination.py
deleted file mode 100644
index ca0ea6ef..00000000
--- a/bigfish/stack/illumination.py
+++ /dev/null
@@ -1,98 +0,0 @@
-# -*- coding: utf-8 -*-
-# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
-# License: BSD 3 clause
-
-"""Illumination correction functions."""
-
-import numpy as np
-
-from .utils import check_array, check_parameter
-from .filter import gaussian_filter
-
-
-# ### Illumination surface ###
-
-def compute_illumination_surface(stacks, sigma=None):
-    """Compute the illumination surface of a specific experiment.
-
-    Parameters
-    ----------
-    stacks : np.ndarray, np.uint
-        Concatenated 5-d tensors along the z-dimension with shape
-        (r, c, z, y, x). They represent different images acquired during a
-        same experiment.
-    sigma : float, int, Tuple(float, int) or List(float, int)
-        Sigma of the gaussian filtering used to smooth the illumination
-        surface.
-
-    Returns
-    -------
-    illumination_surfaces : 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.
-
-    """
-    # check parameters
-    check_array(stacks, ndim=5, dtype=[np.uint8, np.uint16], allow_nan=False)
-    check_parameter(sigma=(float, int, tuple, list, type(None)))
-
-    # initialize illumination surfaces
-    r, c, z, y, x = stacks.shape
-    illumination_surfaces = np.zeros((r, c, y, x))
-
-    # compute mean over the z-dimension
-    mean_stacks = np.mean(stacks, axis=2)
-
-    # separate the channels and the rounds
-    for i_round in range(r):
-        for i_channel in range(c):
-            illumination_surface = mean_stacks[i_round, i_channel, :, :]
-
-            # smooth the surface
-            if sigma is not None:
-                illumination_surface = gaussian_filter(illumination_surface,
-                                                       sigma=sigma,
-                                                       allow_negative=False)
-
-            illumination_surfaces[i_round, i_channel] = illumination_surface
-
-    return illumination_surfaces
-
-
-def correct_illumination_surface(tensor, illumination_surfaces):
-    """Correct a tensor with uneven illumination.
-
-    Parameters
-    ----------
-    tensor : np.ndarray, np.uint
-        A 5-d tensor with shape (r, c, z, y, x).
-    illumination_surfaces : 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.
-
-    Returns
-    -------
-    tensor_corrected : np.ndarray, np.float
-        A 5-d tensor with shape (r, c, z, y, x).
-
-    """
-    # check parameters
-    check_array(tensor, ndim=5, dtype=[np.uint8, np.uint16], allow_nan=False)
-    check_array(illumination_surfaces, ndim=4, dtype=[np.float32, np.float64],
-                allow_nan=False)
-
-    # initialize corrected tensor
-    tensor_corrected = np.zeros_like(tensor)
-
-    # TODO control the multiplication and the division
-    # correct each round/channel independently
-    r, c, _, _, _ = tensor.shape
-    for i_round in range(r):
-        for i_channel in range(c):
-            image_3d = tensor[i_round, i_channel, ...]
-            s = illumination_surfaces[i_round, i_channel]
-            tensor_corrected[i_round, i_channel] = image_3d * np.mean(s) / s
-
-    return tensor_corrected
diff --git a/bigfish/stack/io.py b/bigfish/stack/io.py
index 20d56540..7c3ef735 100644
--- a/bigfish/stack/io.py
+++ b/bigfish/stack/io.py
@@ -17,10 +17,10 @@
 from .utils import check_array
 from .utils import check_parameter
 
+
 # TODO add general read function with mime types
 # TODO saving data in csv does not preserve dtypes
 
-
 # ### Read ###
 
 def read_image(path, sanity_check=False):
@@ -41,21 +41,23 @@ def read_image(path, sanity_check=False):
 
     """
     # check path
-    check_parameter(path=str,
-                    sanity_check=bool)
+    check_parameter(
+        path=str,
+        sanity_check=bool)
 
     # read image
     image = io.imread(path)
 
     # check the output image
     if sanity_check:
-        check_array(image,
-                    dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                           np.int8, np.int16, np.int32, np.int64,
-                           np.float16, np.float32, np.float64,
-                           bool],
-                    ndim=[2, 3, 4, 5],
-                    allow_nan=False)
+        check_array(
+            image,
+            dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+                   np.int8, np.int16, np.int32, np.int64,
+                   np.float16, np.float32, np.float64,
+                   bool],
+            ndim=[2, 3, 4, 5],
+            allow_nan=False)
 
     return image
 
@@ -77,8 +79,9 @@ def read_dv(path, sanity_check=False):
 
     """
     # check path
-    check_parameter(path=str,
-                    sanity_check=bool)
+    check_parameter(
+        path=str,
+        sanity_check=bool)
 
     # read video file
     video = mrc.imread(path)
@@ -86,9 +89,10 @@ def read_dv(path, sanity_check=False):
     # check the output video
     # metadata can be read running 'tensor.mrc.info()'
     if sanity_check:
-        check_array(video,
-                    dtype=[np.uint16, np.int16, np.int32, np.float32],
-                    allow_nan=False)
+        check_array(
+            video,
+            dtype=[np.uint16, np.int16, np.int32, np.float32],
+            allow_nan=False)
 
     return video
 
@@ -137,10 +141,11 @@ def read_array_from_csv(path, dtype=None, delimiter=";", encoding="utf-8"):
 
     """
     # check parameters
-    check_parameter(path=str,
-                    dtype=(type, type(None)),
-                    delimiter=str,
-                    encoding=str)
+    check_parameter(
+        path=str,
+        dtype=(type, type(None)),
+        delimiter=str,
+        encoding=str)
 
     # read csv file
     array = np.loadtxt(path, delimiter=delimiter, encoding=encoding)
@@ -171,9 +176,10 @@ def read_dataframe_from_csv(path, delimiter=";", encoding="utf-8"):
 
     """
     # check parameters
-    check_parameter(path=str,
-                    delimiter=str,
-                    encoding=str)
+    check_parameter(
+        path=str,
+        delimiter=str,
+        encoding=str)
 
     # read csv file
     df = pd.read_csv(path, sep=delimiter, encoding=encoding)
@@ -198,8 +204,9 @@ def read_uncompressed(path, verbose=False):
 
     """
     # check parameters
-    check_parameter(path=str,
-                    verbose=bool)
+    check_parameter(
+        path=str,
+        verbose=bool)
 
     # read array file
     data = np.load(path)
@@ -277,15 +284,17 @@ def save_image(image, path, extension="tif"):
 
     """
     # check image and parameters
-    check_parameter(path=str,
-                    extension=str)
-    check_array(image,
-                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                       np.int8, np.int16, np.int32, np.int64,
-                       np.float16, np.float32, np.float64,
-                       bool],
-                ndim=[2, 3, 4, 5],
-                allow_nan=False)
+    check_parameter(
+        path=str,
+        extension=str)
+    check_array(
+        image,
+        dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+               np.int8, np.int16, np.int32, np.int64,
+               np.float16, np.float32, np.float64,
+               bool],
+        ndim=[2, 3, 4, 5],
+        allow_nan=False)
 
     # check extension and build path
     if "/" in path:
@@ -358,12 +367,13 @@ def save_array(array, path):
     """
     # check array and path
     check_parameter(path=str)
-    check_array(array,
-                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                       np.int8, np.int16, np.int32, np.int64,
-                       np.float16, np.float32, np.float64,
-                       bool],
-                ndim=[2, 3, 4, 5])
+    check_array(
+        array,
+        dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+               np.int8, np.int16, np.int32, np.int64,
+               np.float16, np.float32, np.float64,
+               bool],
+        ndim=[2, 3, 4, 5])
 
     # add extension if necessary
     if ".npy" not in path:
@@ -390,9 +400,10 @@ def save_data_to_csv(data, path, delimiter=";"):
 
     """
     # check parameters
-    check_parameter(data=(pd.DataFrame, pd.Series, np.ndarray),
-                    path=str,
-                    delimiter=str)
+    check_parameter(
+        data=(pd.DataFrame, pd.Series, np.ndarray),
+        path=str,
+        delimiter=str)
 
     # add extension if necessary
     if ".csv" not in path:
@@ -400,11 +411,12 @@ def save_data_to_csv(data, path, delimiter=";"):
 
     # save numpy ndarray in a csv file
     if not isinstance(data, (pd.DataFrame, pd.Series)):
-        check_array(data,
-                    dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                           np.int8, np.int16, np.int32, np.int64,
-                           np.float16, np.float32, np.float64],
-                    ndim=2)
+        check_array(
+            data,
+            dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+                   np.int8, np.int16, np.int32, np.int64,
+                   np.float16, np.float32, np.float64],
+            ndim=2)
 
         if data.dtype == np.float16:
             fmt = "%.4f"
@@ -419,11 +431,19 @@ def save_data_to_csv(data, path, delimiter=";"):
     # save pandas object in a csv file
     elif isinstance(data, pd.Series):
         data = data.to_frame()
-        data.to_csv(path, sep=delimiter, header=True, index=False,
-                    encoding="utf-8")
+        data.to_csv(
+            path,
+            sep=delimiter,
+            header=True,
+            index=False,
+            encoding="utf-8")
     else:
-        data.to_csv(path, sep=delimiter, header=True, index=False,
-                    encoding="utf-8")
+        data.to_csv(
+            path,
+            sep=delimiter,
+            header=True,
+            index=False,
+            encoding="utf-8")
 
 
 def save_cell_extracted(cell_results, path):
@@ -446,8 +466,9 @@ def save_cell_extracted(cell_results, path):
 
     """
     # check parameters
-    check_parameter(cell_results=dict,
-                    path=str)
+    check_parameter(
+        cell_results=dict,
+        path=str)
 
     # add extension if necessary
     if ".npz" not in path:
diff --git a/bigfish/stack/preprocess.py b/bigfish/stack/preprocess.py
index 864cc1cc..56ba00d9 100644
--- a/bigfish/stack/preprocess.py
+++ b/bigfish/stack/preprocess.py
@@ -10,612 +10,120 @@
 
 import numpy as np
 
-from .io import read_image
 from .utils import check_array
 from .utils import check_parameter
-from .utils import check_recipe
-from .utils import check_datamap
 from .utils import check_range_value
-from .utils import fit_recipe
-from .utils import get_path_from_recipe
-from .utils import get_nb_element_per_dimension
-from .utils import count_nb_fov
 
 from skimage import img_as_ubyte
 from skimage import img_as_float32
 from skimage import img_as_float64
 from skimage import img_as_uint
 from skimage.exposure import rescale_intensity
+from skimage.transform import resize
 
 
-# TODO only read in memory one or several channels (and not the entire image)
-# TODO allow new keys to define a recipe
-
-# ### Building stack ###
-
-def build_stacks(data_map, input_dimension=None, sanity_check=False,
-                 return_origin=False):
-    """Generator to build several stacks from recipe-folder pairs.
-
-    To build a stack, a recipe should be linked to a directory including all
-    the files needed to build the stack. The content of the recipe allows to
-    reorganize the different files stored in the directory in order to build
-    a 5-d tensor. If several fields of view (fov) are store in the recipe,
-    several tensors are generated.
-
-    The list 'data_map' takes the form:
-
-        [
-         (recipe_1, path_input_directory_1),
-         (recipe_2, path_input_directory_1),
-         (recipe_3, path_input_directory_1),
-         (recipe_4, path_input_directory_2),
-         ...
-        ]
-
-    The recipe dictionary for one field of view takes the form:
-
-        {
-         "fov": str or List[str], (optional)
-         "z": str or List[str],   (optional)
-         "c": str or List[str],   (optional)
-         "r": str or List[str],   (optional)
-         "ext": str,              (optional)
-         "opt": str,              (optional)
-         "pattern": str
-         }
-
-    - A field of view is defined by a string common to every images belonging
-    to the same field of view ("fov").
-    - At least every images are in 2-d with x and y dimensions. So we need to
-    mention the round-dimension, the channel-dimension and the z-dimension to
-    add ("r", "c" and "z"). For these keys, we provide a list of
-    strings to identify the images to stack.
-    - An extra information to identify the files to stack in the input folder
-    can be provided with the file extension "ext" (usually 'tif' or 'tiff') or
-    an optional morpheme ("opt").
-    - A pattern used to get the filename ("pattern").
-
-    Example 1. Let us assume 3-d images (zyx dimensions) saved as
-    "r03c03f01_405.tif", "r03c03f01_488.tif" and "r03c03f01_561.tif". The first
-    morpheme "r03c03f01" uniquely identifies a 3-d field of view. The second
-    morphemes "405", "488" and "561" identify three different channels we
-    want to stack. There is no round in this experiment. We need to return a
-    tensor with shape (1, 3, z, y, x). Thus, a valid recipe would be:
-
-        {
-         "fov": "r03c03f01",
-         "c": ["405", "488", "561"],
-         "ext": "tif"
-         "pattern": "fov_c.ext"
-         }
-
-    Example 2. Let us assume 2-d images (yx dimensions) saved as
-    "dapi_1.TIFF", "cy3_1.TIFF", "GFP_1.TIFF", "dapi_2.TIFF", "cy3_2.TIFF" and
-    "GFP_2.TIFF". The first morphemes "dapi", "cy3" and "GFP" identify
-    channels. The second morphemes "1" and "2" identify two different fields of
-    view. There is no round and no z dimension in this experiment. We can
-    build two tensors with shape (1, 3, 1, y, x). Thus, a valid recipe would
-    be:
-
-        {
-         "fov": ["1", "2"],
-         "c": ["dapi", "cy3", "GFP"],
-         "ext": "TIFF"
-         "pattern": "c_fov.ext"
-         }
+# TODO replace 'tensor' by 'image'
 
-    Parameters
-    ----------
-    data_map : List[tuple]
-        Map between input directories and recipes.
-    input_dimension : int
-        Number of dimensions of the loaded files. Can speed up the function if
-        provided.
-    sanity_check : bool
-        Check the validity of the loaded tensor. Can slow down the function.
-    return_origin : bool
-        Return the input directory and the recipe used to build the stack.
+# ### Image normalization ###
 
-    Returns
-    -------
-    tensor : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
-    input_directory : str
-        Path of the input directory from where the tensor is built.
-    recipe : dict
-        Recipe used to build the tensor.
-    i_fov : int
-        Index of the fov to build (for a specific recipe).
-
-    """
-    # check parameters
-    check_parameter(data_map=list,
-                    input_dimension=(int, type(None)),
-                    sanity_check=bool,
-                    return_origin=bool)
-    check_datamap(data_map)
-
-    # load and generate tensors for each recipe-folder pair
-    for recipe, input_folder in data_map:
-
-        # load and generate tensors for each fov stored in a recipe
-        nb_fov = count_nb_fov(recipe)
-        for i_fov in range(nb_fov):
-            tensor = build_stack(recipe, input_folder,
-                                 input_dimension=input_dimension,
-                                 sanity_check=sanity_check,
-                                 i_fov=i_fov)
-            if return_origin:
-                yield tensor, input_folder, recipe, i_fov
-            else:
-                yield tensor
-
-
-def build_stack(recipe, input_folder, input_dimension=None, sanity_check=False,
-                i_fov=0):
-    """Build a 5-d stack from the same field of view (fov).
-
-    The recipe dictionary for one field of view takes the form:
-
-        {
-         "fov": str or List[str], (optional)
-         "z": str or List[str],   (optional)
-         "c": str or List[str],   (optional)
-         "r": str or List[str],   (optional)
-         "ext": str,              (optional)
-         "opt": str,              (optional)
-         "pattern": str
-         }
-
-    - A field of view is defined by a string common to every images belonging
-    to the same field of view ("fov").
-    - At least every images are in 2-d with x and y dimensions. So we need to
-    mention the round-dimension, the channel-dimension and the z-dimension to
-    add ("r", "c" and "z"). For these keys, we provide a list of
-    strings to identify the images to stack.
-    - An extra information to identify the files to stack in the input folder
-    can be provided with the file extension "ext" (usually 'tif' or 'tiff') or
-    an optional morpheme ("opt").
-    - A pattern used to get the filename ("pattern").
-
-    Example 1. Let us assume 3-d images (zyx dimensions) saved as
-    "r03c03f01_405.tif", "r03c03f01_488.tif" and "r03c03f01_561.tif". The first
-    morpheme "r03c03f01" uniquely identifies a 3-d field of view. The second
-    morphemes "405", "488" and "561" identify three different channels we
-    want to stack. There is no round in this experiment. We need to return a
-    tensor with shape (1, 3, z, y, x). Thus, a valid recipe would be:
-
-        {
-         "fov": "r03c03f01",
-         "c": ["405", "488", "561"],
-         "ext": "tif"
-         "pattern": "fov_c.ext"
-         }
-
-    Example 2. Let us assume 2-d images (yx dimensions) saved as
-    "dapi_1.TIFF", "cy3_1.TIFF", "GFP_1.TIFF", "dapi_2.TIFF", "cy3_2.TIFF" and
-    "GFP_2.TIFF". The first morphemes "dapi", "cy3" and "GFP" identify
-    channels. The second morphemes "1" and "2" identify two different fields of
-    view. There is no round and no z dimension in this experiment. We can
-    build two tensors with shape (1, 3, 1, y, x). Thus, a valid recipe would
-    be:
-
-        {
-         "fov": ["1", "2"],
-         "c": ["dapi", "cy3", "GFP"],
-         "ext": "TIFF"
-         "pattern": "c_fov.ext"
-         }
+def compute_image_standardization(image):
+    """Normalize image by computing its z score.
 
     Parameters
     ----------
-    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'.
-    input_folder : str
-        Path of the folder containing the images.
-    input_dimension : int
-        Number of dimensions of the loaded files. Can speed up the function if
-        provided.
-    i_fov : int
-        Index of the fov to build.
-    sanity_check : bool
-        Check the validity of the loaded tensor. Can slow down the function.
+    image : np.ndarray
+        Image to normalize with shape (y, x).
 
     Returns
     -------
-    tensor : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
+    normalized_image : np.ndarray
+        Normalized image with shape (y, x).
 
     """
     # check parameters
-    check_recipe(recipe)
-    check_parameter(input_folder=str,
-                    input_dimension=(int, type(None)),
-                    i_fov=int,
-                    sanity_check=bool)
-
-    # build stack from recipe and tif files
-    tensor = _load_stack(recipe, input_folder, input_dimension, i_fov)
-
-    # check the validity of the loaded tensor
-    if sanity_check:
-        check_array(tensor,
-                    dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                           np.int8, np.int16, np.int32, np.int64,
-                           np.float16, np.float32, np.float64,
-                           bool],
-                    ndim=5,
-                    allow_nan=False)
-
-    return tensor
-
-
-def _load_stack(recipe, input_folder, input_dimension=None, i_fov=0):
-    """Build a 5-d tensor from the same field of view (fov).
-
-    The function stacks a set of images using a recipe mapping the
-    different images with the dimensions they represent. Each stacking step
-    add a new dimension to the original tensors (eg. we stack 2-d images with
-    the same xy coordinates to get a 3-d image). If the files we need to build
-    a new dimension are not included in the recipe, an empty dimension is
-    added. This operation is repeated until we get a 5-d tensor. We first
-    operate on the z dimension, then the channels and eventually the rounds.
-
-    The recipe dictionary for one field of view takes the form:
-
-        {
-         "fov": str or List[str], (optional)
-         "z": str or List[str],   (optional)
-         "c": str or List[str],   (optional)
-         "r": str or List[str],   (optional)
-         "ext": str,              (optional)
-         "opt": str,              (optional)
-         "pattern": str
-         }
-
-    - A field of view is defined by a string common to every images belonging
-    to the same field of view ("fov").
-    - At least every images are in 2-d with x and y dimensions. So we need to
-    mention the round-dimension, the channel-dimension and the z-dimension to
-    add ("r", "c" and "z"). For these keys, we provide a list of
-    strings to identify the images to stack.
-    - An extra information to identify the files to stack in the input folder
-    can be provided with the file extension "ext" (usually 'tif' or 'tiff') or
-    an optional morpheme ("opt").
-    - A pattern used to get the filename ("pattern").
-
-    Parameters
-    ----------
-    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'.
-    input_folder : str
-        Path of the folder containing the images.
-    input_dimension : int
-        Number of dimensions of the loaded files.
-    i_fov : int
-        Index of the fov to build.
-
-    Returns
-    -------
-    stack : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
-
-    """
-    # complete the recipe with unused morphemes
-    recipe = fit_recipe(recipe)
-
-    # if the initial dimension of the files is unknown, we read one of them
-    if input_dimension is None:
-        input_dimension = _get_input_dimension(recipe, input_folder)
-
-    # get the number of elements to stack per dimension
-    nb_r, nb_c, nb_z = get_nb_element_per_dimension(recipe)
-
-    # we stack our files according to their initial dimension
-    if input_dimension == 2:
-        stack = _build_stack_from_2d(recipe, input_folder, fov=i_fov,
-                                     nb_r=nb_r, nb_c=nb_c, nb_z=nb_z)
-    elif input_dimension == 3:
-        stack = _build_stack_from_3d(recipe, input_folder, fov=i_fov,
-                                     nb_r=nb_r, nb_c=nb_c)
-    elif input_dimension == 4:
-        stack = _build_stack_from_4d(recipe, input_folder, fov=i_fov,
-                                     nb_r=nb_r)
-    elif input_dimension == 5:
-        stack = _build_stack_from_5d(recipe, input_folder, fov=i_fov)
-    else:
-        raise ValueError("Files do not have the right number of dimensions: "
-                         "{0}. The files we stack should have between 2 and "
-                         "5 dimensions.".format(input_dimension))
-
-    return stack
-
-
-def _build_stack_from_2d(recipe, input_folder, fov=0, nb_r=1, nb_c=1, nb_z=1):
-    """Load and stack 2-d tensors.
-
-    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'.
-    input_folder : str
-        Path of the folder containing the images.
-    fov : int
-        Index of the fov to build.
-    nb_r : int
-        Number of round file to stack in order to get a 5-d tensor.
-    nb_c : int
-        Number of channel file to stack in order to get a 4-d tensor.
-    nb_z : int
-        Number of z file to stack in order to get a 3-d tensor.
-
-    Returns
-    -------
-    tensor_5d : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
-
-    """
-
-    # load and stack successively z, channel then round elements
-    tensors_4d = []
-    for r in range(nb_r):
-
-        # load and stack channel elements (3-d tensors)
-        tensors_3d = []
-        for c in range(nb_c):
-
-            # load and stack z elements (2-d tensors)
-            tensors_2d = []
-            for z in range(nb_z):
-                path = get_path_from_recipe(recipe, input_folder, fov=fov,
-                                            r=r, c=c, z=z)
-                tensor_2d = read_image(path)
-                tensors_2d.append(tensor_2d)
-
-            # stack 2-d tensors in 3-d
-            tensor_3d = np.stack(tensors_2d, axis=0)
-            tensors_3d.append(tensor_3d)
-
-        # stack 3-d tensors in 4-d
-        tensor_4d = np.stack(tensors_3d, axis=0)
-        tensors_4d.append(tensor_4d)
-
-    # stack 4-d tensors in 5-d
-    tensor_5d = np.stack(tensors_4d, axis=0)
-
-    return tensor_5d
-
-
-def _build_stack_from_3d(recipe, input_folder, fov=0, nb_r=1, nb_c=1):
-    """Load and stack 3-d tensors.
-
-    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'.
-    input_folder : str
-        Path of the folder containing the images.
-    fov : int
-        Index of the fov to build.
-    nb_r : int
-        Number of round file to stack in order to get a 5-d tensor.
-    nb_c : int
-        Number of channel file to stack in order to get a 4-d tensor.
-
-    Returns
-    -------
-    tensor_5d : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
-
-    """
-    # load and stack successively channel elements then round elements
-    tensors_4d = []
-    for r in range(nb_r):
-
-        # load and stack channel elements (3-d tensors)
-        tensors_3d = []
-        for c in range(nb_c):
-            path = get_path_from_recipe(recipe, input_folder, fov=fov, r=r,
-                                        c=c)
-            tensor_3d = read_image(path)
-            tensors_3d.append(tensor_3d)
+    check_array(image, ndim=2, dtype=[np.uint8, np.uint16, np.float32])
 
-        # stack 3-d tensors in 4-d
-        tensor_4d = np.stack(tensors_3d, axis=0)
-        tensors_4d.append(tensor_4d)
+    # 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)))
 
-    # stack 4-d tensors in 5-d
-    tensor_5d = np.stack(tensors_4d, axis=0)
-
-    return tensor_5d
+    # compute mean and standard deviation
+    m = np.mean(image)
+    adjusted_stddev = max(np.std(image), 1.0 / np.sqrt(image.size))
 
+    # normalize image
+    normalized_image = (image - m) / adjusted_stddev
 
-def _build_stack_from_4d(recipe, input_folder, fov=0, nb_r=1):
-    """Load and stack 4-d tensors.
+    return normalized_image
 
-    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'.
-    input_folder : str
-        Path of the folder containing the images.
-    fov : int
-        Index of the fov to build.
-    nb_r : int
-        Number of round file to stack in order to get a 5-d tensor.
-
-    Returns
-    -------
-    tensor_5d : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
-
-    """
-    # load each file from a new round element and stack them
-    tensors_4d = []
-    for r in range(nb_r):
-        path = get_path_from_recipe(recipe, input_folder, fov=fov, r=r)
-        tensor_4d = read_image(path)
-        tensors_4d.append(tensor_4d)
 
-    # stack 4-d tensors in 5-d
-    tensor_5d = np.stack(tensors_4d, axis=0)
-
-    return tensor_5d
-
-
-def _build_stack_from_5d(recipe, input_folder, fov=0):
-    """Load directly a 5-d tensor.
-
-    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'.
-    input_folder : str
-        Path of the folder containing the images.
-    fov : int
-        Index of the fov to build.
-
-    Returns
-    -------
-    tensor_5d : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
-
-    """
-    # the recipe can only contain one file with a 5-d tensor per fov
-    path = get_path_from_recipe(recipe, input_folder, fov=fov)
-    tensor_5d = read_image(path)
-
-    return tensor_5d
-
-
-def _get_input_dimension(recipe, input_folder):
-    """ Load an arbitrary image to get the original dimension of the files.
-
-    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'.
-    input_folder : str
-        Path of the folder containing the images.
-
-    Returns
-    -------
-    nb_dim : int
-        Number of dimensions of the original file.
-
-    """
-    # get a valid path from the recipe
-    path = get_path_from_recipe(recipe, input_folder)
-
-    # load the image and return the number of dimensions
-    image = read_image(path)
-    nb_dim = image.ndim
+def rescale(tensor, channel_to_stretch=None, stretching_percentile=99.9):
+    """Rescale tensor values up to its dtype range (unsigned/signed integers)
+    or between 0 and 1 (float).
 
-    return nb_dim
+    Each round and each channel is rescaled independently. Tensor has between
+    2 to 5 dimensions, in the following order: (round, channel, z, y, x).
 
+    By default, we rescale the tensor intensity range to its dtype range (or
+    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``).
 
-def build_stack_no_recipe(paths, input_dimension=None, sanity_check=False):
-    """Build 5-d stack without recipe.
+    To be consistent with skimage, 64-bit (unsigned) integer images are not
+    supported.
 
     Parameters
     ----------
-    paths : List[str]
-        List of the paths to stack.
-    input_dimension : str
-        Number of dimensions of the loaded files. Can speed up the function if
-        provided.
-    sanity_check : bool
-        Check the validity of the loaded tensor. Can slow down the function.
+    tensor : np.ndarray
+        Tensor to rescale.
+    channel_to_stretch : int, List[int] or Tuple[int]
+        Channel to stretch. If None, minimum and maximum of each channel are
+        used as the intensity range to rescale.
+    stretching_percentile : float or int
+        Percentile to determine the maximum intensity value used to rescale
+        the image. If 1, the maximum pixel intensity is used to rescale the
+        image.
 
     Returns
     -------
     tensor : np.ndarray
-        Tensor with shape (round, channel, z, y, x).
+        Tensor rescaled.
 
     """
     # check parameters
-    check_parameter(paths=(str, list),
-                    input_dimension=(int, type(None)),
-                    sanity_check=bool)
-
-    # build stack from tif files
-    tensor = _load_stack_no_recipe(paths, input_dimension)
-
-    # check the validity of the loaded tensor
-    if sanity_check:
-        check_array(tensor,
-                    dtype=[np.uint8, np.uint16, np.uint32,
-                           np.int8, np.int16, np.int32,
-                           np.float16, np.float32, np.float64,
-                           bool],
-                    ndim=5,
-                    allow_nan=False)
-
-    return tensor
-
-
-def _load_stack_no_recipe(paths, input_dimension=None):
-    """Build a 5-d tensor from the same field of view (fov), without recipe.
+    check_parameter(
+        tensor=np.ndarray,
+        channel_to_stretch=(int, list, tuple, type(None)),
+        stretching_percentile=(int, float))
+    check_array(
+        tensor,
+        ndim=[2, 3, 4, 5],
+        dtype=[np.uint8, np.uint16, np.uint32,
+               np.int8, np.int16, np.int32,
+               np.float16, np.float32, np.float64])
+    check_range_value(tensor, min_=0)
 
-    Files with a path listed are stacked together, then empty dimensions are
-    added up to 5.
+    # enlist 'channel_to_stretch' if necessary
+    if channel_to_stretch is None:
+        channel_to_stretch = []
+    elif isinstance(channel_to_stretch, int):
+        channel_to_stretch = [channel_to_stretch]
 
-    Parameters
-    ----------
-    paths : List[str]
-        List of the file to stack.
-    input_dimension : str
-        Number of dimensions of the loaded files.
+    # wrap tensor in 5-d if necessary
+    tensor_5d, original_ndim = _wrap_5d(tensor)
 
-    Returns
-    -------
-    tensor_5d : np.ndarray, np.uint
-        Tensor with shape (round, channel, z, y, x).
+    # rescale
+    tensor_5d = _rescale_5d(
+        tensor_5d,
+        channel_to_stretch=channel_to_stretch,
+        stretching_percentile=stretching_percentile)
 
-    """
-    # load an image and get the number of dimensions
-    if input_dimension is None:
-        testfile = read_image(paths[0])
-        input_dimension = testfile.ndim
-
-    # get stacks
-    stacks = []
-    for path in paths:
-        s = read_image(path)
-        stacks.append(s)
-
-    # we stack our files according to their initial dimension
-    if input_dimension == 2:
-        tensor_3d = np.stack(stacks, axis=0)
-        tensor_5d = tensor_3d[np.newaxis, np.newaxis, :, :, :]
-    elif input_dimension == 3:
-        tensor_4d = np.stack(stacks, axis=0)
-        tensor_5d = tensor_4d[np.newaxis, :, :, :, :]
-    elif input_dimension == 4:
-        tensor_5d = np.stack(stacks, axis=0)
-    elif input_dimension == 5 and len(stacks) == 1:
-        tensor_5d = stacks[0]
-    else:
-        raise ValueError("Files do not have the right number of dimensions: "
-                         "{0}. The files we stack should have between 2 and "
-                         "5 dimensions.".format(input_dimension))
+    # rebuild the original tensor shape
+    tensor = _unwrap_5d(tensor_5d, original_ndim)
 
-    return tensor_5d
+    return tensor
 
 
 def _wrap_5d(tensor):
@@ -677,73 +185,6 @@ def _unwrap_5d(tensor_5d, original_ndim):
     return tensor
 
 
-# ### Image normalization ###
-
-def rescale(tensor, channel_to_stretch=None, stretching_percentile=99.9):
-    """Rescale tensor values up to its dtype range (unsigned/signed integers)
-    or between 0 and 1 (float).
-
-    Each round and each channel is rescaled independently. Tensor has between
-    2 to 5 dimensions, in the following order: (round, channel, z, y, x).
-
-    By default, we rescale the tensor intensity range to its dtype range (or
-    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``).
-
-    To be consistent with skimage, 64-bit (unsigned) integer images are not
-    supported.
-
-    Parameters
-    ----------
-    tensor : np.ndarray
-        Tensor to rescale.
-    channel_to_stretch : int, List[int] or Tuple[int]
-        Channel to stretch. If None, minimum and maximum of each channel are
-        used as the intensity range to rescale.
-    stretching_percentile : float or int
-        Percentile to determine the maximum intensity value used to rescale
-        the image. If 1, the maximum pixel intensity is used to rescale the
-        image.
-
-    Returns
-    -------
-    tensor : np.ndarray
-        Tensor rescaled.
-
-    """
-    # check parameters
-    check_parameter(tensor=np.ndarray,
-                    channel_to_stretch=(int, list, tuple, type(None)),
-                    stretching_percentile=(int, float))
-    check_array(tensor,
-                ndim=[2, 3, 4, 5],
-                dtype=[np.uint8, np.uint16, np.uint32,
-                       np.int8, np.int16, np.int32,
-                       np.float16, np.float32, np.float64])
-    check_range_value(tensor, min_=0)
-
-    # enlist 'channel_to_stretch' if necessary
-    if channel_to_stretch is None:
-        channel_to_stretch = []
-    elif isinstance(channel_to_stretch, int):
-        channel_to_stretch = [channel_to_stretch]
-
-    # wrap tensor in 5-d if necessary
-    tensor_5d, original_ndim = _wrap_5d(tensor)
-
-    # rescale
-    tensor_5d = _rescale_5d(tensor_5d,
-                            channel_to_stretch=channel_to_stretch,
-                            stretching_percentile=stretching_percentile)
-
-    # rebuild the original tensor shape
-    tensor = _unwrap_5d(tensor_5d, original_ndim)
-
-    return tensor
-
-
 def _rescale_5d(tensor, channel_to_stretch, stretching_percentile):
     """Rescale tensor values up to its dtype range (unsigned/signed integers)
     or between 0 and 1 (float).
@@ -794,12 +235,14 @@ def _rescale_5d(tensor, channel_to_stretch, stretching_percentile):
             # rescale channel
             if c in channel_to_stretch:
                 pa, pb = np.percentile(channel, (0, stretching_percentile))
-                channel_rescaled = rescale_intensity(channel,
-                                                     in_range=(pa, pb),
-                                                     out_range=target_range)
+                channel_rescaled = rescale_intensity(
+                    channel,
+                    in_range=(pa, pb),
+                    out_range=target_range)
             else:
-                channel_rescaled = rescale_intensity(channel,
-                                                     out_range=target_range)
+                channel_rescaled = rescale_intensity(
+                    channel,
+                    out_range=target_range)
             channels.append(channel_rescaled)
 
         # stack channels
@@ -834,11 +277,12 @@ def cast_img_uint8(tensor, catch_warning=False):
 
     """
     # check tensor dtype
-    check_array(tensor,
-                ndim=[2, 3, 4, 5],
-                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                       np.int8, np.int16, np.int32, np.int64,
-                       np.float16, np.float32, np.float64])
+    check_array(
+        tensor,
+        ndim=[2, 3, 4, 5],
+        dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+               np.int8, np.int16, np.int32, np.int64,
+               np.float16, np.float32, np.float64])
     if tensor.dtype in [np.float16, np.float32, np.float64]:
         check_range_value(tensor, min_=0, max_=1)
     elif tensor.dtype in [np.int8, np.int16, np.int32, np.int64]:
@@ -886,11 +330,12 @@ def cast_img_uint16(tensor, catch_warning=False):
 
     """
     # check tensor dtype
-    check_array(tensor,
-                ndim=[2, 3, 4, 5],
-                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                       np.int8, np.int16, np.int32, np.int64,
-                       np.float16, np.float32, np.float64])
+    check_array(
+        tensor,
+        ndim=[2, 3, 4, 5],
+        dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+               np.int8, np.int16, np.int32, np.int64,
+               np.float16, np.float32, np.float64])
     if tensor.dtype in [np.float16, np.float32, np.float64]:
         check_range_value(tensor, min_=0, max_=1)
     elif tensor.dtype in [np.int8, np.int16, np.int32, np.int64]:
@@ -937,11 +382,12 @@ def cast_img_float32(tensor, catch_warning=False):
 
     """
     # check tensor dtype
-    check_array(tensor,
-                ndim=[2, 3, 4, 5],
-                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                       np.int8, np.int16, np.int32, np.int64,
-                       np.float16, np.float32, np.float64])
+    check_array(
+        tensor,
+        ndim=[2, 3, 4, 5],
+        dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+               np.int8, np.int16, np.int32, np.int64,
+               np.float16, np.float32, np.float64])
 
     # cast tensor
     if catch_warning:
@@ -972,13 +418,101 @@ def cast_img_float64(tensor):
 
     """
     # check tensor dtype
-    check_array(tensor,
-                ndim=[2, 3, 4, 5],
-                dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
-                       np.int8, np.int16, np.int32, np.int64,
-                       np.float16, np.float32, np.float64])
+    check_array(
+        tensor,
+        ndim=[2, 3, 4, 5],
+        dtype=[np.uint8, np.uint16, np.uint32, np.uint64,
+               np.int8, np.int16, np.int32, np.int64,
+               np.float16, np.float32, np.float64])
 
     # cast tensor
     tensor = img_as_float64(tensor)
 
     return tensor
+
+
+# ### 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
+        Image to resize.
+    output_shape : Tuple[int]
+        Shape of the resized image.
+    method : str
+        Interpolation method to use.
+
+    Returns
+    -------
+    image_resized : np.ndarray
+        Resized image.
+
+    """
+    # check parameters
+    check_parameter(output_shape=tuple, method=str)
+    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:
+        raise ValueError("Method {0} is not available. Choose between "
+                         "'bilinear' or 'nearest' instead.".format(method))
+
+    # cast output dtype
+    image_resized = image_resized.astype(image.dtype)
+
+    return image_resized
+
+
+def get_marge_padding(height, width, x):
+    """Pad image to make its shape a multiple of `x`.
+
+    Parameters
+    ----------
+    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
+    -------
+    marge_padding : List[List]
+        List of lists with the format
+        [[`marge_height_t`, `marge_height_b`], [`marge_width_l`,
+        `marge_width_r`]].
+
+    """
+    # check parameters
+    check_parameter(height=int, width=int, x=int)
+
+    # 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
diff --git a/bigfish/stack/projection.py b/bigfish/stack/projection.py
index 18b787e1..d2eb1b61 100644
--- a/bigfish/stack/projection.py
+++ b/bigfish/stack/projection.py
@@ -125,8 +125,9 @@ def focus_projection(image, proportion=5, neighborhood_size=7,
     """
     # check parameters
     check_array(image, ndim=3, dtype=[np.uint8, np.uint16])
-    check_parameter(proportion=(float, int),
-                    neighborhood_size=int)
+    check_parameter(
+        proportion=(float, int),
+        neighborhood_size=int)
     if isinstance(proportion, float) and 0 <= proportion <= 1:
         pass
     elif isinstance(proportion, int) and 0 <= proportion:
@@ -193,10 +194,11 @@ def _one_hot_3d(indices, depth, return_boolean=False):
     """
     # 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])
+    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)
@@ -240,9 +242,10 @@ def in_focus_selection(image, focus, proportion):
 
     """
     # check parameters
-    check_array(image,
-                ndim=3,
-                dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    check_array(
+        image,
+        ndim=3,
+        dtype=[np.uint8, np.uint16, np.float32, np.float64])
 
     # select and keep best z-slices
     indices_to_keep = get_in_focus_indices(focus, proportion)
diff --git a/bigfish/stack/quality.py b/bigfish/stack/quality.py
index 8e5ab61e..bf159d54 100644
--- a/bigfish/stack/quality.py
+++ b/bigfish/stack/quality.py
@@ -46,9 +46,10 @@ def compute_focus(image, neighborhood_size=31):
 
     """
     # check parameters
-    check_array(image,
-                ndim=[2, 3],
-                dtype=[np.uint8, np.uint16, np.float32, np.float64])
+    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)
 
@@ -120,12 +121,14 @@ def _compute_focus_2d(image_2d, 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)
+    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 48307a1f..dcf28510 100644
--- a/bigfish/stack/utils.py
+++ b/bigfish/stack/utils.py
@@ -7,8 +7,6 @@
 """
 
 import os
-import re
-import copy
 import inspect
 import hashlib
 
@@ -39,9 +37,10 @@ def check_df(df, features=None, features_without_nan=None):
 
     """
     # check parameters
-    check_parameter(df=(pd.DataFrame, pd.Series),
-                    features=(list, type(None)),
-                    features_without_nan=(list, type(None)))
+    check_parameter(
+        df=(pd.DataFrame, pd.Series),
+        features=(list, type(None)),
+        features_without_nan=(list, type(None)))
 
     # check features
     if features is not None:
@@ -125,10 +124,11 @@ def check_array(array, ndim=None, dtype=None, allow_nan=True):
 
     """
     # check parameters
-    check_parameter(array=np.ndarray,
-                    ndim=(int, list, type(None)),
-                    dtype=(type, list, type(None)),
-                    allow_nan=bool)
+    check_parameter(
+        array=np.ndarray,
+        ndim=(int, list, type(None)),
+        dtype=(type, list, type(None)),
+        allow_nan=bool)
 
     # check the dtype
     if dtype is not None:
@@ -241,332 +241,6 @@ def check_range_value(array, min_=None, max_=None):
     return True
 
 
-# ### Recipe management (sanity checks, fitting) ###
-
-def check_recipe(recipe, data_directory=None):
-    """Check and validate a recipe.
-
-    Checking a recipe consists in validating its filename pattern and the
-    content of the dictionary.
-
-    Parameters
-    ----------
-    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`.
-    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.
-
-    Returns
-    -------
-    _ : bool
-        Assert if the recipe is well formatted.
-
-    """
-    # check parameters
-    check_parameter(recipe=dict,
-                    data_directory=(str, type(None)))
-
-    # check the filename pattern
-    if "pattern" not in recipe:
-        raise KeyError("A recipe should have a filename pattern "
-                       "('pattern' keyword).")
-    recipe_pattern = recipe["pattern"]
-    if not isinstance(recipe_pattern, str):
-        raise TypeError("'pattern' should be a string, not a {0}."
-                        .format(type(recipe_pattern)))
-
-    # count the different dimensions to combinate in the recipe (among
-    # 'fov', 'r', 'c' and 'z')
-    dimensions = re.findall("fov|r|c|z", recipe_pattern)
-
-    # each dimension can only appear once in the filename pattern
-    if len(dimensions) != len(set(dimensions)):
-        raise ValueError("The pattern used in recipe is wrong, a dimension "
-                         "appears several times: {0}".format(recipe_pattern))
-
-    # check keys and values of the recipe
-    for key, value in recipe.items():
-        if key not in ['fov', 'r', 'c', 'z', 'ext', 'opt', 'pattern']:
-            raise KeyError("The recipe can only contain the keys 'fov', 'r', "
-                           "'c', 'z', 'ext', 'opt' or 'pattern'. Not '{0}'."
-                           .format(key))
-        if not isinstance(value, (list, str)):
-            raise TypeError("A recipe can only contain lists or strings, "
-                            "not {0}.".format(type(value)))
-
-    # check that requested files exist
-    if data_directory is not None:
-        if not os.path.isdir(data_directory):
-            raise NotADirectoryError("Directory does not exist: {0}"
-                                     .format(data_directory))
-        recipe = fit_recipe(recipe)
-        nb_r, nb_c, nb_z = get_nb_element_per_dimension(recipe)
-        nb_fov = count_nb_fov(recipe)
-        for fov in range(nb_fov):
-            for r in range(nb_r):
-                for c in range(nb_c):
-                    for z in range(nb_z):
-                        path = get_path_from_recipe(recipe, data_directory,
-                                                    fov=fov, r=r, c=c, z=z)
-                        if not os.path.isfile(path):
-                            raise FileNotFoundError("File does not exist: {0}"
-                                                    .format(path))
-
-    return True
-
-
-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
-    also initialized.
-
-    Parameters
-    ----------
-    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`.
-
-    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
-        necessary.
-
-    """
-    # check parameters
-    check_parameter(recipe=dict)
-
-    # initialize recipe
-    new_recipe = copy.deepcopy(recipe)
-
-    # initialize and fit the dimensions 'fov', 'r', 'c' and 'z'
-    for key in ['fov', 'r', 'c', 'z']:
-        if key not in new_recipe:
-            new_recipe[key] = [None]
-        value = new_recipe[key]
-        if isinstance(value, str):
-            new_recipe[key] = [value]
-
-    # initialize the dimensions 'ext', 'opt'
-    for key in ['ext', 'opt']:
-        if key not in new_recipe:
-            new_recipe[key] = ""
-
-    return new_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
-    also initialized.
-
-    Parameters
-    ----------
-    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`.
-
-    Returns
-    -------
-    _ : bool
-        Indicates if the recipe is fitted or not
-
-    """
-    # all keys should be initialized in the new recipe, with a list or a string
-    for key in ['fov', 'r', 'c', 'z']:
-        if key not in recipe or not isinstance(recipe[key], list):
-            return False
-    for key in ['ext', 'opt']:
-        if key not in recipe or not isinstance(recipe[key], str):
-            return False
-    if 'pattern' not in recipe or not isinstance(recipe['pattern'], str):
-        return False
-
-    return True
-
-
-def get_path_from_recipe(recipe, input_folder, fov=0, r=0, c=0, z=0):
-    """Build the path of a file from a recipe and the indices of specific
-    elements.
-
-    Parameters
-    ----------
-    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`.
-    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.
-    r : int
-        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.
-    z : int
-        Index of the `z` element in the recipe to use in the filename.
-
-    Returns
-    -------
-    path : str
-        Path of the file to load.
-
-    """
-    # check parameters
-    check_parameter(recipe=dict,
-                    input_folder=str,
-                    fov=int,
-                    r=int,
-                    c=int,
-                    z=int)
-
-    # check if the recipe is fitted
-    if not _is_recipe_fitted(recipe):
-        recipe = fit_recipe(recipe)
-
-    # build a map of the elements' indices
-    map_element_index = {"fov": fov, "r": r, "c": c, "z": z}
-
-    # get filename pattern and decompose it
-    recipe_pattern = recipe["pattern"]
-    path_elements = re.findall("fov|r|c|z|ext|opt", recipe_pattern)
-    path_separators = re.split("fov|r|c|z|ext|opt", recipe_pattern)
-
-    # get filename recombining elements of the recipe
-    filename = path_separators[0]  # usually an empty string
-    for (element_name, separator) in zip(path_elements, path_separators[1:]):
-
-        # if we need an element from a list of elements of the same dimension
-        # (eg. to pick a specific channel 'c' among a list of channels)
-        if element_name in map_element_index:
-            element_index = map_element_index[element_name]
-            element = recipe[element_name][element_index]
-
-        # if this element is unique for all the recipe (eg. 'fov')
-        else:
-            element = recipe[element_name]
-
-        # the filename is built ensuring the order of apparition of the
-        # different morphemes and their separators
-        filename += element
-        filename += separator
-
-    # get path
-    path = os.path.join(input_folder, filename)
-
-    return path
-
-
-def get_nb_element_per_dimension(recipe):
-    """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`.
-
-    Returns
-    -------
-    nb_r : int
-        Number of rounds to be stacked.
-    nb_c : int
-        Number of channels to be stacked.
-    nb_z : int
-        Number of z layers to be stacked.
-
-    """
-    # check parameters
-    check_parameter(recipe=dict)
-
-    # check if the recipe is fitted
-    if not _is_recipe_fitted(recipe):
-        recipe = fit_recipe(recipe)
-
-    return len(recipe["r"]), len(recipe["c"]), len(recipe["z"])
-
-
-def count_nb_fov(recipe):
-    """Count the number of different fields of view that can be defined from
-    the recipe.
-
-    Parameters
-    ----------
-    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`.
-
-    Returns
-    -------
-    nb_fov : int
-        Number of different fields of view in the recipe.
-
-    """
-    # check parameters
-    check_parameter(recipe=dict)
-
-    # check if the recipe is fitted
-    if not _is_recipe_fitted(recipe):
-        recipe = fit_recipe(recipe)
-
-    # a good recipe should have a list in the 'fov' key
-    if not isinstance(recipe["fov"], list):
-        raise TypeError("'fov' should be a List or a str, not {0}"
-                        .format(type(recipe["fov"])))
-    else:
-        return len(recipe["fov"])
-
-
-def check_datamap(data_map):
-    """Check and validate a data map.
-
-    Checking a data map consists in validating the recipe-folder pairs.
-
-    Parameters
-    ----------
-    data_map : List[tuple]
-        Map between input directories and recipes.
-
-    Returns
-    -------
-    _ : bool
-        Assert if the data map is well formatted.
-
-    """
-    check_parameter(data_map=list)
-    for pair in data_map:
-        if not isinstance(pair, (tuple, list)):
-            raise TypeError("A data map is a list with tuples or lists. "
-                            "Not {0}".format(type(pair)))
-        if len(pair) != 2:
-            raise ValueError("Elements of a data map are tuples or lists that "
-                             "map a recipe (dict) to an input directory "
-                             "(string). Here {0} elements are given {1}"
-                             .format(len(pair), pair))
-        (recipe, input_folder) = pair
-        if not isinstance(input_folder, str):
-            raise TypeError("A data map map a recipe (dict) to an input "
-                            "directory (string). Not ({0}, {1})"
-                            .format(type(recipe), type(input_folder)))
-        check_recipe(recipe, data_directory=input_folder)
-
-    return True
-
-
 # ### Sanity checks parameters ###
 
 def check_parameter(**kwargs):
@@ -653,9 +327,10 @@ def load_and_save_url(remote_url, directory, filename=None):
 
     """
     # check parameters
-    check_parameter(remote_url=str,
-                    directory=str,
-                    filename=(str, type(None)))
+    check_parameter(
+        remote_url=str,
+        directory=str,
+        filename=(str, type(None)))
 
     # get output path
     if filename is None:
@@ -685,8 +360,9 @@ def check_hash(path, expected_hash):
 
     """
     # check parameter
-    check_parameter(path=str,
-                    expected_hash=str)
+    check_parameter(
+        path=str,
+        expected_hash=str)
 
     # compute hash value
     hash_value = compute_hash(path)
@@ -939,87 +615,3 @@ def centered_moving_average(array, 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 radius
diff --git a/build_package.sh b/build_package.sh
new file mode 100755
index 00000000..ac48d341
--- /dev/null
+++ b/build_package.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+rm -r build/*
+rm -r dist/*
+
+pip install --upgrade pip setuptools wheel twine
+
+python setup.py sdist bdist_wheel
+twine upload dist/*
\ No newline at end of file
diff --git a/docs/source/classification/extraction.rst b/docs/source/classification/extraction.rst
index ae8dc654..899631ba 100644
--- a/docs/source/classification/extraction.rst
+++ b/docs/source/classification/extraction.rst
@@ -3,7 +3,7 @@
 Single-cell identification
 **************************
 
-.. currentmodule:: bigfish.stack
+.. currentmodule:: bigfish.multistack
 
 Functions to exploit detection and segmentation results, by identifying
 individual cells and their objects.
@@ -13,11 +13,11 @@ Identify and remove transcription sites
 
 Define transcription sites as clustered RNAs detected inside nucleus:
 
-* :func:`bigfish.stack.remove_transcription_site`
+* :func:`bigfish.multistack.remove_transcription_site`
 
 More generally, identify detected objects within a specific cellular region:
 
-* :func:`bigfish.stack.identify_objects_in_region`
+* :func:`bigfish.multistack.identify_objects_in_region`
 
 .. autofunction:: remove_transcription_site
 .. autofunction:: identify_objects_in_region
@@ -29,12 +29,12 @@ 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`
+* :func:`bigfish.multistack.extract_cell`
+* :func:`bigfish.multistack.extract_spots_from_frame`
+* :func:`bigfish.multistack.summarize_extraction_results`
 
-See an example of application `here <http://localhost:8888/notebooks/big-fish-
-examples/notebooks/6%20-%20Extract%20cell%20level%20results.ipynb/>`_.
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/blob/master/notebooks/6%20-%20Extract%20cell%20level%20results.ipynb>`_.
 
 .. autofunction:: extract_cell
 .. autofunction:: extract_spots_from_frame
@@ -48,13 +48,13 @@ 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`
+* :func:`bigfish.multistack.center_mask_coord`
+* :func:`bigfish.multistack.from_boundaries_to_surface`
+* :func:`bigfish.multistack.from_surface_to_boundaries`
+* :func:`bigfish.multistack.from_binary_to_coord`
+* :func:`bigfish.multistack.complete_coord_boundaries`
+* :func:`bigfish.multistack.from_coord_to_frame`
+* :func:`bigfish.multistack.from_coord_to_surface`
 
 .. autofunction:: center_mask_coord
 .. autofunction:: from_boundaries_to_surface
@@ -62,4 +62,4 @@ manipulates coordinates:
 .. autofunction:: from_binary_to_coord
 .. autofunction:: complete_coord_boundaries
 .. autofunction:: from_coord_to_frame
-.. autofunction:: from_coord_to_surface
+.. autofunction:: from_coord_to_surface
\ No newline at end of file
diff --git a/docs/source/classification/features.rst b/docs/source/classification/features.rst
index d23a50c0..3f6498aa 100644
--- a/docs/source/classification/features.rst
+++ b/docs/source/classification/features.rst
@@ -5,6 +5,19 @@ Features engineering
 
 .. currentmodule:: bigfish.classification
 
+Prepare input coordinates
+=========================
+
+Format input coordinates and compute intermediary results to prepare features
+computation:
+
+.. autofunction:: prepare_extracted_data
+
+------------
+
+Compute features
+================
+
 Functions to compute features about cell morphology and RNAs localization.
 There are two main functions to compute spatial and morphological features are:
 
@@ -22,8 +35,8 @@ Group of features can be computed separately:
 * :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/>`_.
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/blob/master/notebooks/7%20-%20Analyze%20coordinates.ipynb>`_.
 
 .. autofunction:: compute_features
 .. autofunction:: get_features_name
@@ -34,4 +47,4 @@ examples/notebooks/7%20-%20Analyze%20coordinates.ipynb/>`_.
 .. autofunction:: features_topography
 .. autofunction:: features_foci
 .. autofunction:: features_area
-.. autofunction:: features_centrosome
+.. autofunction:: features_centrosome
\ No newline at end of file
diff --git a/docs/source/classification/input.rst b/docs/source/classification/input.rst
deleted file mode 100644
index 826d1db3..00000000
--- a/docs/source/classification/input.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-.. _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/detection/cluster.rst b/docs/source/detection/cluster.rst
index 3e8ed7ea..10d3b48f 100644
--- a/docs/source/detection/cluster.rst
+++ b/docs/source/detection/cluster.rst
@@ -9,6 +9,6 @@ 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/>`_.
+examples/blob/master/notebooks/5%20-%20Detect%20spots.ipynb>`_.
 
 .. autofunction:: detect_clusters
diff --git a/docs/source/detection/colocalization.rst b/docs/source/detection/colocalization.rst
new file mode 100644
index 00000000..7889bafe
--- /dev/null
+++ b/docs/source/detection/colocalization.rst
@@ -0,0 +1,18 @@
+.. _colocalization overview:
+
+Colocalization
+**************
+
+.. currentmodule:: bigfish.multistack
+
+Match colocalized spots over two different channels:
+
+* :func:`bigfish.multistack.detect_spots_colocalization`
+
+Visualize the impact of distance threshold to discriminate between the
+colocalized spots and the distant ones:
+
+* :func:`bigfish.multistack.get_elbow_value_colocalized`
+
+.. autofunction:: detect_spots_colocalization
+.. autofunction:: get_elbow_value_colocalized
\ No newline at end of file
diff --git a/docs/source/detection/dense.rst b/docs/source/detection/dense.rst
index 21233544..7b4d8c76 100644
--- a/docs/source/detection/dense.rst
+++ b/docs/source/detection/dense.rst
@@ -21,7 +21,7 @@ It is also possible to perform the main steps of this decomposition separately:
 * :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/>`_.
+examples/blob/master/notebooks/5%20-%20Detect%20spots.ipynb>`_.
 
 .. autofunction:: decompose_dense
 .. autofunction:: get_dense_region
diff --git a/docs/source/detection/spots.rst b/docs/source/detection/spots.rst
index c0f9aa5c..e3f8d753 100644
--- a/docs/source/detection/spots.rst
+++ b/docs/source/detection/spots.rst
@@ -28,7 +28,7 @@ It is also possible to perform the main steps of the spot detection separately:
 * :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/>`_.
+examples/blob/master/notebooks/5%20-%20Detect%20spots.ipynb>`_.
 
 .. autofunction:: detect_spots
 .. autofunction:: local_maximum_detection
@@ -44,9 +44,11 @@ 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_breaking_point`
 * :func:`bigfish.detection.get_elbow_values`
 
 .. autofunction:: automated_threshold_setting
+.. autofunction:: get_breaking_point
 .. autofunction:: get_elbow_values
 
 ------------
@@ -54,6 +56,9 @@ heuristic function to to automatically set this threshold:
 Compute signal-to-noise ratio
 =============================
 
-Compute a signal-to-noise ratio (SNR) for the image:
+.. currentmodule:: bigfish.detection
+
+Compute a signal-to-noise ratio (SNR) for the image, based on the detected
+spots:
 
 .. autofunction:: compute_snr_spots
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 7e68302b..e22267a3 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -10,9 +10,9 @@ 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
+`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:
+environments.html>`_ environment.  In a terminal run the command:
 
 .. code-block:: bash
 
@@ -24,7 +24,7 @@ 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
+Use the package manager `pip <https://pip.pypa.io/en/stable>`_ to install
 Big-FISH. In a terminal run the command:
 
 .. code-block:: bash
@@ -35,7 +35,7 @@ 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:
+fish>`_ and install it manually with the following commands:
 
 .. code-block:: bash
 
@@ -49,7 +49,7 @@ Examples
 ========
 
 Several examples are available as `Jupyter notebooks <https://github.com/fish-
-quant/big-fish-examples/tree/master/notebooks/>`_:
+quant/big-fish-examples/tree/master/notebooks>`_:
 
 #. Read and write images.
 #. Normalize and filter images.
@@ -62,7 +62,7 @@ quant/big-fish-examples/tree/master/notebooks/>`_:
 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
+examples%252Fnotebooks%26branch%3Dmaster>`_. The remote server can take a bit
 of time to start.
 
 ------------
@@ -86,14 +86,13 @@ API reference
    detection/dense
    detection/subpixel
    detection/cluster
+   detection/colocalization
 
 ------------
 
 .. toctree::
    :caption: Segmentation
 
-   segmentation/thresholding
-   segmentation/input
    segmentation/nucleus
    segmentation/cell
    segmentation/postprocessing
@@ -104,10 +103,8 @@ API reference
    :caption: Analysis
 
    classification/extraction
-   classification/input
    classification/features
 
-
 ------------
 
 .. toctree::
@@ -118,11 +115,12 @@ API reference
    plot/plot_segmentation
    plot/plot_coordinate
 
-
 ------------
 
-* :ref:`Utility functions<utils overview>`: Sanity checks, get constant values,
-  compute hash, save plots, etc...
+.. toctree::
+   :caption: Utils
+
+   utils/utils
 
 ------------
 
@@ -130,4 +128,4 @@ 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
+<https://github.com/fish-quant/big-fish/issues>`_ on Github.
\ No newline at end of file
diff --git a/docs/source/plot/plot_detection.rst b/docs/source/plot/plot_detection.rst
index 8287ccb2..3912b782 100644
--- a/docs/source/plot/plot_detection.rst
+++ b/docs/source/plot/plot_detection.rst
@@ -1,7 +1,7 @@
 .. _plot_detection overview:
 
-Detection plots
-***************
+Detection plot
+**************
 
 .. currentmodule:: bigfish.plot
 
@@ -18,7 +18,9 @@ Visualize the reference spot computed for an image:
 Visualize the elbow curve used to automatically set a detection threshold:
 
 * :func:`bigfish.plot.plot_elbow`
+* :func:`bigfish.plot.plot_elbow_colocalized`
 
 .. autofunction:: plot_detection
 .. autofunction:: plot_reference_spot
 .. autofunction:: plot_elbow
+.. autofunction:: plot_elbow_colocalized
diff --git a/docs/source/plot/plot_image.rst b/docs/source/plot/plot_image.rst
index eabefc1b..2141288b 100644
--- a/docs/source/plot/plot_image.rst
+++ b/docs/source/plot/plot_image.rst
@@ -1,7 +1,7 @@
 .. _plot_image overview:
 
-Field of view plots
-*******************
+Field of view plot
+******************
 
 .. currentmodule:: bigfish.plot
 
diff --git a/docs/source/plot/plot_segmentation.rst b/docs/source/plot/plot_segmentation.rst
index 37c3cb7e..ab1538ba 100644
--- a/docs/source/plot/plot_segmentation.rst
+++ b/docs/source/plot/plot_segmentation.rst
@@ -1,7 +1,7 @@
 .. _plot_segmentation overview:
 
-Segmentation plots
-******************
+Segmentation plot
+*****************
 
 .. currentmodule:: bigfish.plot
 
diff --git a/docs/source/segmentation/cell.rst b/docs/source/segmentation/cell.rst
index dcf9aac9..60e4e69b 100644
--- a/docs/source/segmentation/cell.rst
+++ b/docs/source/segmentation/cell.rst
@@ -38,8 +38,8 @@ 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/>`_.
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/blob/master/notebooks/4%20-%20Segment%20nuclei%20and%20cells.ipynb>`_.
 
 .. autofunction:: unet_distance_edge_double
 .. autofunction:: apply_unet_distance_double
diff --git a/docs/source/segmentation/input.rst b/docs/source/segmentation/input.rst
deleted file mode 100644
index ded2bf72..00000000
--- a/docs/source/segmentation/input.rst
+++ /dev/null
@@ -1,16 +0,0 @@
-.. _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
index 5943e8af..1666fce4 100644
--- a/docs/source/segmentation/nucleus.rst
+++ b/docs/source/segmentation/nucleus.rst
@@ -7,6 +7,13 @@ Nucleus segmentation
 
 Functions used to segment nuclei.
 
+Apply thresholding
+==================
+
+Thresholding is the most standard and direct binary segmentation method:
+
+.. autofunction:: thresholding
+
 Apply a Unet-based model (3-classes)
 ====================================
 
@@ -19,8 +26,8 @@ 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/>`_.
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/blob/master/notebooks/4%20-%20Segment%20nuclei%20and%20cells.ipynb>`_.
 
 .. autofunction:: unet_3_classes_nuc
 .. autofunction:: apply_unet_3_classes
diff --git a/docs/source/segmentation/postprocessing.rst b/docs/source/segmentation/postprocessing.rst
index c5f70e91..e81c42d1 100644
--- a/docs/source/segmentation/postprocessing.rst
+++ b/docs/source/segmentation/postprocessing.rst
@@ -20,18 +20,13 @@ 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/>`_.
+See an example of application `here <https://github.com/fish-quant/big-fish-
+examples/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
 
 ------------
 
@@ -49,3 +44,14 @@ Compute statistics for each segmented instance:
 .. autofunction:: compute_mean_convexity_ratio
 .. autofunction:: compute_surface_ratio
 .. autofunction:: count_instances
+
+------------
+
+Match cells and nuclei
+======================
+
+.. currentmodule:: bigfish.multistack
+
+Match nuclei and cells:
+
+.. autofunction:: match_nuc_cell
\ No newline at end of file
diff --git a/docs/source/segmentation/thresholding.rst b/docs/source/segmentation/thresholding.rst
deleted file mode 100644
index a34aa994..00000000
--- a/docs/source/segmentation/thresholding.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-.. _thresholding overview:
-
-Thresholding
-************
-
-.. currentmodule:: bigfish.segmentation
-
-Thresholding is the most standard and direct segmentation method:
-
-.. autofunction:: thresholding
diff --git a/docs/source/stack/preprocessing.rst b/docs/source/stack/preprocessing.rst
index c10ec8bd..e51d127e 100644
--- a/docs/source/stack/preprocessing.rst
+++ b/docs/source/stack/preprocessing.rst
@@ -5,14 +5,31 @@ Image preparation
 
 .. currentmodule:: bigfish.stack
 
-Functions used to normalize, cast, project or filter images.
+Functions used to normalize, format, cast, project or filter images.
 
 Normalize images
 ================
 
 Rescale or contrast pixel intensity:
 
+* :func:`bigfish.stack.rescale`
+* :func:`bigfish.stack.compute_image_standardization`
+
 .. autofunction:: rescale
+.. autofunction:: compute_image_standardization
+
+------------
+
+Format images
+=============
+
+Resize and pad images:
+
+* :func:`bigfish.stack.resize_image`
+* :func:`bigfish.stack.get_marge_padding`
+
+.. autofunction:: resize_image
+.. autofunction:: get_marge_padding
 
 ------------
 
@@ -101,7 +118,7 @@ Build a 2D projection by removing the out-of-focus z-slices/pixels:
 
 * :func:`bigfish.stack.focus_projection`
 
-.. autofunction:: bigfish.stack.compute_focus
+.. autofunction:: 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
index ca6924be..e9a4d574 100644
--- a/docs/source/utils/utils.rst
+++ b/docs/source/utils/utils.rst
@@ -57,14 +57,18 @@ Compute moving average
 
 ------------
 
-Compute spot scale
-==================
+Convert pixels and nanometers
+=============================
 
-* :func:`bigfish.stack.get_sigma`
-* :func:`bigfish.stack.get_radius`
+.. currentmodule:: bigfish.detection
 
-.. autofunction:: get_sigma
-.. autofunction:: get_radius
+* :func:`bigfish.detection.convert_spot_coordinates`
+* :func:`bigfish.detection.get_object_radius_pixel`
+* :func:`bigfish.detection.get_object_radius_nm`
+
+.. autofunction:: convert_spot_coordinates
+.. autofunction:: get_object_radius_pixel
+.. autofunction:: get_object_radius_nm
 
 ------------
 
@@ -79,4 +83,4 @@ Format and save plots
 
 .. autofunction:: save_plot
 .. autofunction:: get_minmax_values
-.. autofunction:: create_colormap
+.. autofunction:: create_colormap
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index c57c7e4d..2e67bf53 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 numpy == 1.16.0
-scikit-learn == 0.20.2
+scikit-learn == 0.21.0
 scikit-image == 0.14.2
 scipy == 1.4.1
 matplotlib == 3.0.2
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 79fca121..7d58e965 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -7,6 +7,7 @@ attrs==19.3.0
 Babel==2.9.0
 backcall==0.1.0
 bleach==3.1.5
+build==0.5.1
 cachetools==4.1.0
 certifi==2020.4.5.2
 chardet==3.0.4
@@ -53,7 +54,7 @@ opt-einsum==3.2.1
 packaging==20.0
 pandas==0.24.0
 parso==0.3.1
-pep517==0.8.2
+pep517==0.10.0
 pexpect==4.6.0
 pickleshare==0.7.5
 Pillow==5.4.1
@@ -80,7 +81,7 @@ requests-oauthlib==1.3.0
 requests-toolbelt==0.9.1
 rsa==4.6
 scikit-image==0.14.2
-scikit-learn==0.20.2
+scikit-learn==0.21.0
 scipy==1.4.1
 six==1.12.0
 snowballstemmer==2.0.0


Test patch:
diff --git a/bigfish/classification/tests/__init__.py b/bigfish/classification/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bigfish/classification/tests/test_features.py b/bigfish/classification/tests/test_features.py
new file mode 100644
index 00000000..055f8b90
--- /dev/null
+++ b/bigfish/classification/tests/test_features.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.classification.features module.
+"""
+
+# TODO add test bigfish.classification.compute_features
+# TODO add test bigfish.classification.get_features_name
+# TODO add test bigfish.classification.features_distance
+# TODO add test bigfish.classification.features_in_out_nucleus
+# TODO add test bigfish.classification.features_protrusion
+# TODO add test bigfish.classification.features_dispersion
+# TODO add test bigfish.classification.features_topography
+# TODO add test bigfish.classification.features_foci
+# TODO add test bigfish.classification.features_area
+# TODO add test bigfish.classification.features_centrosome
diff --git a/bigfish/classification/tests/test_input_preparation.py b/bigfish/classification/tests/test_input_preparation.py
new file mode 100644
index 00000000..d90aa238
--- /dev/null
+++ b/bigfish/classification/tests/test_input_preparation.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.classification.input_preparation module.
+"""
+
+# TODO add test bigfish.classification.prepare_extracted_data
diff --git a/bigfish/detection/tests/test_spot_detection.py b/bigfish/detection/tests/test_spot_detection.py
index e5cb2314..2d6a85fb 100644
--- a/bigfish/detection/tests/test_spot_detection.py
+++ b/bigfish/detection/tests/test_spot_detection.py
@@ -10,3 +10,4 @@
 # TODO test bigfish.detection.local_maximum_detection
 # TODO test bigfish.detection.spots_thresholding
 # TODO test bigfish.detection.automated_threshold_setting
+# TODO test bigfish.detection.get_elbow_values
diff --git a/bigfish/detection/tests/test_spot_modeling.py b/bigfish/detection/tests/test_spot_modeling.py
index 95b1e2a2..fdbfaa32 100644
--- a/bigfish/detection/tests/test_spot_modeling.py
+++ b/bigfish/detection/tests/test_spot_modeling.py
@@ -6,7 +6,10 @@
 Unitary tests for bigfish.detection.spot_modeling module.
 """
 
-# TODO test bigfish.detection.fit_subpixel
-# TODO test bigfish.detection.build_reference_spot
+
 # TODO test bigfish.detection.modelize_spot
+# TODO test bigfish.detection.initialize_grid
+# TODO test bigfish.detection.gaussian_2d
+# TODO test bigfish.detection.gaussian_3d
 # TODO test bigfish.detection.precompute_erf
+# TODO test bigfish.detection.fit_subpixel
diff --git a/bigfish/detection/tests/test_utils.py b/bigfish/detection/tests/test_utils.py
new file mode 100644
index 00000000..e115147b
--- /dev/null
+++ b/bigfish/detection/tests/test_utils.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.detection.utils module.
+"""
+
+# TODO test bigfish.detection.convert_spot_coordinates
+# TODO add test for bigfish.detection.get_object_radius_pixel
+# TODO add test for bigfish.detection.get_object_radius_nm
+# TODO add test for bigfish.detection.build_reference_spot
+# TODO add test for bigfish.detection.compute_snr_spots
+# TODO add test for bigfish.detection.get_breaking_point
diff --git a/bigfish/multistack/tests/__init__.py b/bigfish/multistack/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bigfish/multistack/tests/test_colocalization.py b/bigfish/multistack/tests/test_colocalization.py
new file mode 100644
index 00000000..4344e5ca
--- /dev/null
+++ b/bigfish/multistack/tests/test_colocalization.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.multistack.colocalization module.
+"""
+
+# TODO add test bigfish.multistack.detect_spots_colocalization
+# TODO add test bigfish.multistack.get_elbow_value_colocalized
diff --git a/bigfish/stack/tests/test_postprocess.py b/bigfish/multistack/tests/test_postprocess.py
similarity index 82%
rename from bigfish/stack/tests/test_postprocess.py
rename to bigfish/multistack/tests/test_postprocess.py
index 2937141d..cb9e21e1 100644
--- a/bigfish/stack/tests/test_postprocess.py
+++ b/bigfish/multistack/tests/test_postprocess.py
@@ -3,26 +3,22 @@
 # License: BSD 3 clause
 
 """
-Unitary tests for bigfish.stack.postprocess module.
+Unitary tests for bigfish.multistack.postprocess module.
 """
 
 import pytest
 
 import numpy as np
-import bigfish.stack as stack
+
+import bigfish.multistack as multistack
 
 from numpy.testing import assert_array_equal
 
-# TODO add test bigfish.stack.extract_cell
-# TODO add test bigfish.stack.extract_spots_from_frame
-# TODO add test bigfish.stack.center_mask_coord
-# TODO add test bigfish.stack.from_boundaries_to_surface
-# TODO add test bigfish.stack.from_surface_to_boundaries
-# TODO add test bigfish.stack.from_binary_to_coord
-# TODO add test bigfish.stack.complete_coord_boundaries
-# TODO add test bigfish.stack.from_coord_to_frame
-# TODO add test bigfish.stack.from_coord_to_surface
 
+# TODO add test bigfish.multistack.match_nuc_cell
+# TODO add test bigfish.multistack.extract_cell
+# TODO add test bigfish.multistack.extract_spots_from_frame
+# TODO add test bigfish.multistack.summarize_extraction_results
 
 @pytest.mark.parametrize("ndim", [2, 3])
 @pytest.mark.parametrize("mask_dtype", [
@@ -43,7 +39,8 @@ def test_identify_objects_in_region(ndim, mask_dtype, spot_dtype):
     spots = np.concatenate((spots_in, spots_out))
 
     # test
-    spots_in_, spots_out_ = stack.identify_objects_in_region(mask, spots, ndim)
+    spots_in_, spots_out_ = multistack.identify_objects_in_region(
+        mask, spots, ndim)
     assert_array_equal(spots_in_, spots_in)
     assert_array_equal(spots_out_, spots_out)
     assert spots_in_.dtype == spots_in.dtype
@@ -95,7 +92,7 @@ def test_remove_transcription_site(ndim, mask_dtype, spot_dtype):
     all_clusters = np.concatenate((ts, foci))
 
     # test
-    spots_out_ts_, foci_, ts_ = stack.remove_transcription_site(
+    spots_out_ts_, foci_, ts_ = multistack.remove_transcription_site(
         spots, all_clusters, nuc_mask, ndim)
     assert_array_equal(spots_out_ts_, spots_out_ts)
     assert spots_out_ts_.dtype == spots_out_ts.dtype
diff --git a/bigfish/multistack/tests/test_preprocess.py b/bigfish/multistack/tests/test_preprocess.py
new file mode 100644
index 00000000..dc67100c
--- /dev/null
+++ b/bigfish/multistack/tests/test_preprocess.py
@@ -0,0 +1,202 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.multistack.preprocess module.
+"""
+
+import os
+import pytest
+import tempfile
+
+import numpy as np
+
+import bigfish.stack as stack
+import bigfish.multistack as multistack
+
+from numpy.testing import assert_array_equal
+
+
+# ### Test stack building ###
+
+def test_build_stacks_from_recipe():
+    # build a temporary directory and save tensors inside
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        # field of view 1
+        test_nuc = np.zeros((8, 8, 8), dtype=np.uint8)
+        test_cyt = np.zeros((8, 8, 8), dtype=np.uint8)
+        test_rna = np.zeros((8, 8, 8), dtype=np.uint8)
+        path_nuc = os.path.join(tmp_dir, "test_nuc_1.tif")
+        path_cyt = os.path.join(tmp_dir, "test_cyt_1.tif")
+        path_rna = os.path.join(tmp_dir, "test_rna_1.tif")
+        stack.save_image(test_nuc, path_nuc)
+        stack.save_image(test_cyt, path_cyt)
+        stack.save_image(test_rna, path_rna)
+
+        # field of view 2
+        test_nuc = np.zeros((5, 5, 5), dtype=np.uint16)
+        test_cyt = np.zeros((5, 5, 5), dtype=np.uint16)
+        test_rna = np.zeros((5, 5, 5), dtype=np.uint16)
+        path_nuc = os.path.join(tmp_dir, "test_nuc_2.tif")
+        path_cyt = os.path.join(tmp_dir, "test_cyt_2.tif")
+        path_rna = os.path.join(tmp_dir, "test_rna_2.tif")
+        stack.save_image(test_nuc, path_nuc)
+        stack.save_image(test_cyt, path_cyt)
+        stack.save_image(test_rna, path_rna)
+
+        # define recipe to read tensors
+        recipe_1 = {"fov": ["1", "2"],
+                    "c": ["nuc", "cyt", "rna"],
+                    "opt": "test",
+                    "ext": "tif",
+                    "pattern": "opt_c_fov.ext"}
+
+        # build tensor without prior information
+        tensor = multistack.build_stack(recipe_1, input_folder=tmp_dir)
+        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
+        assert_array_equal(tensor, expected_tensor)
+        assert tensor.dtype == np.uint8
+
+        # build tensor with prior information
+        tensor = multistack.build_stack(recipe_1,
+                                        input_folder=tmp_dir,
+                                        input_dimension=3)
+        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
+        assert_array_equal(tensor, expected_tensor)
+        assert tensor.dtype == np.uint8
+
+        # build tensors with different fields of view
+        tensor = multistack.build_stack(recipe_1,
+                                        input_folder=tmp_dir,
+                                        input_dimension=3,
+                                        i_fov=0)
+        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
+        assert_array_equal(tensor, expected_tensor)
+        assert tensor.dtype == np.uint8
+        tensor = multistack.build_stack(recipe_1,
+                                        input_folder=tmp_dir,
+                                        input_dimension=3,
+                                        i_fov=1)
+        expected_tensor = np.zeros((1, 3, 5, 5, 5), dtype=np.uint16)
+        assert_array_equal(tensor, expected_tensor)
+        assert tensor.dtype == np.uint16
+
+        # wrong recipe
+        recipe_wrong = {"fov": "test",
+                        "c": ["nuc", "cyt", "rna"],
+                        "ext": "tif",
+                        "pattern": "fov_c.ext"}
+        with pytest.raises(FileNotFoundError):
+            multistack.build_stack(recipe_wrong,
+                                   input_folder=tmp_dir,
+                                   input_dimension=3)
+
+        # wrong path
+        with pytest.raises(FileNotFoundError):
+            multistack.build_stack(recipe_1,
+                                   input_folder="/foo/bar",
+                                   input_dimension=3)
+
+
+def test_build_stacks_from_datamap():
+    # build a temporary directory and save tensors inside
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        # field of view 1
+        test_nuc = np.zeros((8, 8, 8), dtype=np.uint8)
+        test_cyt = np.zeros((8, 8, 8), dtype=np.uint8)
+        test_rna = np.zeros((8, 8, 8), dtype=np.uint8)
+        path_nuc = os.path.join(tmp_dir, "test_nuc_1.tif")
+        path_cyt = os.path.join(tmp_dir, "test_cyt_1.tif")
+        path_rna = os.path.join(tmp_dir, "test_rna_1.tif")
+        stack.save_image(test_nuc, path_nuc)
+        stack.save_image(test_cyt, path_cyt)
+        stack.save_image(test_rna, path_rna)
+
+        # field of view 2
+        test_nuc = np.zeros((5, 5, 5), dtype=np.uint16)
+        test_cyt = np.zeros((5, 5, 5), dtype=np.uint16)
+        test_rna = np.zeros((5, 5, 5), dtype=np.uint16)
+        path_nuc = os.path.join(tmp_dir, "test_nuc_2.tif")
+        path_cyt = os.path.join(tmp_dir, "test_cyt_2.tif")
+        path_rna = os.path.join(tmp_dir, "test_rna_2.tif")
+        stack.save_image(test_nuc, path_nuc)
+        stack.save_image(test_cyt, path_cyt)
+        stack.save_image(test_rna, path_rna)
+
+        # define datamap to read  tensors
+        recipe_1 = {"fov": ["1", "2"],
+                    "c": ["nuc", "cyt", "rna"],
+                    "opt": "test",
+                    "ext": "tif",
+                    "pattern": "opt_c_fov.ext"}
+        recipe_2 = {"fov": "2",
+                    "c": ["nuc", "cyt", "rna"],
+                    "opt": "test",
+                    "ext": "tif",
+                    "pattern": "opt_c_fov.ext"}
+        data_map = [(recipe_1, tmp_dir), (recipe_2, tmp_dir)]
+
+        # build stacks from generator
+        generator = multistack.build_stacks(data_map, input_dimension=3)
+        expected_tensors = [np.zeros((1, 3, 8, 8, 8), dtype=np.uint8),
+                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16),
+                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16)]
+        for i, tensor in enumerate(generator):
+            expected_tensor = expected_tensors[i]
+            assert_array_equal(tensor, expected_tensor)
+            assert tensor.dtype == expected_tensor.dtype
+
+        # build stacks from generator with metadata
+        generator = multistack.build_stacks(data_map,
+                                            input_dimension=3,
+                                            return_origin=True)
+        expected_tensors = [np.zeros((1, 3, 8, 8, 8), dtype=np.uint8),
+                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16),
+                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16)]
+        expected_recipes = [recipe_1, recipe_1, recipe_2]
+        expected_i_fov = [0, 1, 0]
+        for i, (tensor, input_folder, recipe, i_fov) in enumerate(generator):
+            expected_tensor = expected_tensors[i]
+            assert_array_equal(tensor, expected_tensor)
+            assert tensor.dtype == expected_tensor.dtype
+            assert input_folder == tmp_dir
+            assert recipe == expected_recipes[i]
+            assert i_fov == expected_i_fov[i]
+
+        # wrong datamap
+        data_map = [(recipe_1, 3), (recipe_2, tmp_dir)]
+        generator = multistack.build_stacks(data_map, input_dimension=3)
+        with pytest.raises(TypeError):
+            next(generator)
+        data_map = [(recipe_1, "foo/bar"), (recipe_2, tmp_dir)]
+        generator = multistack.build_stacks(data_map, input_dimension=3)
+        with pytest.raises(NotADirectoryError):
+            next(generator)
+
+
+def test_build_stack_from_path():
+    # build a temporary directory and save tensors inside
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        # field of view
+        test_nuc = np.zeros((8, 8, 8), dtype=np.uint8)
+        test_cyt = np.zeros((8, 8, 8), dtype=np.uint8)
+        test_rna = np.zeros((8, 8, 8), dtype=np.uint8)
+        path_nuc = os.path.join(tmp_dir, "test_nuc.tif")
+        path_cyt = os.path.join(tmp_dir, "test_cyt.tif")
+        path_rna = os.path.join(tmp_dir, "test_rna.tif")
+        stack.save_image(test_nuc, path_nuc)
+        stack.save_image(test_cyt, path_cyt)
+        stack.save_image(test_rna, path_rna)
+
+        # build tensor from paths
+        paths = [path_nuc, path_cyt, path_rna]
+        tensor = multistack.build_stack_no_recipe(paths, input_dimension=3)
+        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
+        assert_array_equal(tensor, expected_tensor)
+        assert tensor.dtype == np.uint8
+
+        # wrong paths
+        paths = [path_nuc, path_cyt, "/foo/bar/test_rna.tif"]
+        with pytest.raises(FileNotFoundError):
+            multistack.build_stack_no_recipe(paths, input_dimension=3)
diff --git a/bigfish/multistack/tests/test_utils.py b/bigfish/multistack/tests/test_utils.py
new file mode 100644
index 00000000..89ff2ce1
--- /dev/null
+++ b/bigfish/multistack/tests/test_utils.py
@@ -0,0 +1,216 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.stack.utils module.
+"""
+
+import os
+import pytest
+import tempfile
+
+import bigfish.multistack as multistack
+
+
+# ### Test recipes ###
+
+def test_check_recipe():
+    # build a temporary directory with two files
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        path = os.path.join(tmp_dir, "experience_1_dapi_fov_1.tif")
+        with open(path, 'w') as f:
+            f.write("dapi file")
+        path = os.path.join(tmp_dir, "experience_1_smfish_fov_1.tif")
+        with open(path, 'w') as f:
+            f.write("smFISH file")
+
+        # test the consistency of the check function when it should work
+        good_recipe_1 = {"fov": "fov_1",
+                         "c": ["dapi", "smfish"],
+                         "opt": "experience_1",
+                         "ext": "tif",
+                         "pattern": "opt_c_fov.ext"}
+        assert multistack.check_recipe(good_recipe_1, data_directory=None)
+        assert multistack.check_recipe(good_recipe_1, data_directory=tmp_dir)
+
+        # case with a good recipe but when a file is missing
+        good_recipe_2 = {"fov": "fov_1",
+                         "c": ["dapi", "smfish", "cellmask"],
+                         "opt": "experience_1",
+                         "ext": "tif",
+                         "pattern": "opt_c_fov.ext"}
+        assert multistack.check_recipe(good_recipe_2, data_directory=None)
+        with pytest.raises(FileNotFoundError):
+            multistack.check_recipe(good_recipe_2, data_directory=tmp_dir)
+
+        # cases without a 'pattern' key with a string value
+        bad_recipe_1 = {"fov": "fov_1",
+                        "c": ["dapi", "smfish"],
+                        "opt": "experience_1",
+                        "ext": "tif"}
+        bad_recipe_2 = {"fov": "fov_1",
+                        "c": ["dapi", "smfish"],
+                        "opt": "experience_1",
+                        "ext": "tif",
+                        "pattern": ["opt_c_fov.ext"]}
+        with pytest.raises(KeyError):
+            multistack.check_recipe(bad_recipe_1, data_directory=None)
+        with pytest.raises(TypeError):
+            multistack.check_recipe(bad_recipe_2, data_directory=None)
+
+        # case with a wrong pattern (repetitive key)
+        bad_recipe_3 = {"fov": "fov_1",
+                        "c": ["dapi", "smfish"],
+                        "opt": "experience_1",
+                        "ext": "tif",
+                        "pattern": "opt_c_fov_fov.ext"}
+        with pytest.raises(ValueError):
+            multistack.check_recipe(bad_recipe_3, data_directory=None)
+
+        # case with wrong key or value
+        bad_recipe_4 = {"fov": "fov_1",
+                        "channel": ["dapi", "smfish"],
+                        "optional": "experience_1",
+                        "extension": "tif",
+                        "pattern": "opt_c_fov.ext"}
+        bad_recipe_5 = {"fov": "fov_1",
+                        "c": ["dapi", "smfish"],
+                        "opt": 1,
+                        "ext": "tif",
+                        "pattern": "opt_c_fov.ext"}
+        with pytest.raises(KeyError):
+            multistack.check_recipe(bad_recipe_4, data_directory=None)
+        with pytest.raises(TypeError):
+            multistack.check_recipe(bad_recipe_5, data_directory=None)
+
+
+def test_fit_recipe():
+    # build a recipe to fit
+    good_recipe = {"fov": "fov_1",
+                   "c": ["dapi", "smfish"],
+                   "opt": "experience_1",
+                   "ext": "tif",
+                   "pattern": "opt_c_fov.ext"}
+
+    # fit recipe
+    new_recipe = multistack.fit_recipe(good_recipe)
+
+    # all keys should be initialized in the new recipe, with a list or a string
+    for key in ['fov', 'r', 'c', 'z']:
+        assert key in new_recipe
+        assert isinstance(new_recipe[key], list)
+    for key in ['ext', 'opt']:
+        assert key in new_recipe
+        assert isinstance(new_recipe[key], str)
+    assert 'pattern' in new_recipe
+    assert isinstance(new_recipe['pattern'], str)
+
+    # test that fitting an already fitted recipe does not change anything
+    new_recip_bis = multistack.fit_recipe(new_recipe)
+    assert new_recip_bis == new_recipe
+
+
+def test_path_from_recipe():
+    # build a temporary directory with one file
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        path = os.path.join(tmp_dir, "experience_1_dapi_fov_1.tif")
+        with open(path, 'w') as f:
+            f.write("dapi file")
+
+        # build a recipe to read the file
+        good_recipe = {"fov": "fov_1",
+                       "c": "dapi",
+                       "opt": "experience_1",
+                       "ext": "tif",
+                       "pattern": "opt_c_fov.ext"}
+
+        # test the path
+        path_dapi = multistack.get_path_from_recipe(good_recipe, tmp_dir, c=0)
+        assert os.path.isfile(path_dapi)
+
+
+def test_element_per_dimension():
+    # build a recipe to test
+    good_recipe = {"fov": "fov_1",
+                   "c": ["dapi", "smfish"],
+                   "opt": "experience_1",
+                   "ext": "tif",
+                   "pattern": "opt_c_fov.ext"}
+
+    # test the number of elements to be stacked
+    nb_r, nb_c, nb_z = multistack.get_nb_element_per_dimension(good_recipe)
+    assert nb_r == 1
+    assert nb_c == 2
+    assert nb_z == 1
+
+
+def test_nb_fov():
+    # case when 'fov' key is a string
+    good_recipe_1 = {"fov": "fov_1",
+                     "c": ["dapi", "smfish"],
+                     "opt": "experience_1",
+                     "ext": "tif",
+                     "pattern": "opt_c_fov.ext"}
+    nb_fov = multistack.count_nb_fov(good_recipe_1)
+    assert nb_fov == 1
+
+    # case when 'fov' key is a list
+    good_recipe_2 = {"fov": ["fov_1", "fov_2"],
+                     "c": ["dapi", "smfish"],
+                     "opt": "experience_1",
+                     "ext": "tif",
+                     "pattern": "opt_c_fov.ext"}
+    nb_fov = multistack.count_nb_fov(good_recipe_2)
+    assert nb_fov == 2
+
+    # case when 'fov' key does not exist
+    good_recipe_3 = {"c": ["dapi", "smfish"],
+                     "opt": "experience_1",
+                     "ext": "tif",
+                     "pattern": "opt_c_fov.ext"}
+    nb_fov = multistack.count_nb_fov(good_recipe_3)
+    assert nb_fov == 1
+
+    # case when the 'fov' key is not a string or a list
+    with pytest.raises(TypeError):
+        bad_recipe = {"fov": 1,
+                      "c": ["dapi", "smfish"],
+                      "opt": "experience_1",
+                      "ext": "tif",
+                      "pattern": "opt_c_fov.ext"}
+        multistack.count_nb_fov(bad_recipe)
+
+
+def test_check_datamap():
+    # build a temporary directory with two files
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        path = os.path.join(tmp_dir, "experience_1_dapi_fov_1.tif")
+        with open(path, 'w') as f:
+            f.write("dapi file")
+        path = os.path.join(tmp_dir, "experience_1_smfish_fov_1.tif")
+        with open(path, 'w') as f:
+            f.write("smFISH file")
+
+        # test the consistency of the check function
+        recipe = {"fov": "fov_1",
+                  "c": ["dapi", "smfish"],
+                  "opt": "experience_1",
+                  "ext": "tif",
+                  "pattern": "opt_c_fov.ext"}
+        datamap = [(recipe, tmp_dir)]
+        assert multistack.check_datamap(datamap)
+        datamap = [[recipe, tmp_dir]]
+        assert multistack.check_datamap(datamap)
+        datamap = [(None, tmp_dir)]
+        with pytest.raises(TypeError):
+            multistack.check_datamap(datamap)
+        datamap = [(recipe, 3)]
+        with pytest.raises(TypeError):
+            multistack.check_datamap(datamap)
+        datamap = [(recipe, "/foo/bar")]
+        with pytest.raises(NotADirectoryError):
+            multistack.check_datamap(datamap)
+        datamap = [(recipe, tmp_dir, None)]
+        with pytest.raises(ValueError):
+            multistack.check_datamap(datamap)
diff --git a/bigfish/segmentation/test/test_cell_segmentation.py b/bigfish/segmentation/test/test_cell_segmentation.py
index 334c45a3..488016fc 100644
--- a/bigfish/segmentation/test/test_cell_segmentation.py
+++ b/bigfish/segmentation/test/test_cell_segmentation.py
@@ -7,6 +7,9 @@
 """
 
 
+# TODO add test for bigfish.segmentation.unet_distance_edge_double
+# TODO add test for bigfish.segmentation.apply_unet_distance_double
+# TODO add test for bigfish.segmentation.from_distance_to_instances
 # TODO add test for bigfish.segmentation.cell_watershed
 # TODO add test for bigfish.segmentation.get_watershed_relief
 # TODO add test for bigfish.segmentation.apply_watershed
diff --git a/bigfish/segmentation/test/test_nuc_segmentation.py b/bigfish/segmentation/test/test_nuc_segmentation.py
index efbcc08e..cc3fae3d 100644
--- a/bigfish/segmentation/test/test_nuc_segmentation.py
+++ b/bigfish/segmentation/test/test_nuc_segmentation.py
@@ -8,3 +8,6 @@
 
 
 # TODO add test for bigfish.segmentation.remove_segmented_nuc
+# TODO add test for bigfish.segmentation.unet_3_classes_nuc
+# TODO add test for bigfish.segmentation.apply_unet_3_classes
+# TODO add test for bigfish.segmentation.from_3_classes_to_instances
diff --git a/bigfish/segmentation/test/test_postprocess.py b/bigfish/segmentation/test/test_postprocess.py
new file mode 100644
index 00000000..7ba066d2
--- /dev/null
+++ b/bigfish/segmentation/test/test_postprocess.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.segmentation.postprocess module.
+"""
+
+
+# TODO add test for bigfish.segmentation.label_instances
+# TODO add test for bigfish.segmentation.merge_labels
+# TODO add test for bigfish.segmentation.clean_segmentation
+# TODO add test for bigfish.segmentation.remove_disjoint
+# TODO add test for bigfish.segmentation.center_mask_coord
+# TODO add test for bigfish.segmentation.from_boundaries_to_surface
+# TODO add test for bigfish.segmentation.from_surface_to_boundaries
+# TODO add test for bigfish.segmentation.from_binary_to_coord
+# TODO add test for bigfish.segmentation.complete_coord_boundaries
+# TODO add test for bigfish.segmentation.from_coord_to_frame
+# TODO add test for bigfish.segmentation.from_coord_to_surface
diff --git a/bigfish/segmentation/test/test_utils.py b/bigfish/segmentation/test/test_utils.py
index c1a45681..4a748e7d 100644
--- a/bigfish/segmentation/test/test_utils.py
+++ b/bigfish/segmentation/test/test_utils.py
@@ -7,9 +7,11 @@
 """
 
 
-# TODO add test for bigfish.segmentation.label_instances
-# TODO add test for bigfish.segmentation.merge_labels
 # TODO add test for bigfish.segmentation.thresholding
-# TODO add test for bigfish.segmentation.clean_segmentation
-# TODO add test for bigfish.segmentation.compute_instances_mean_diameter
-# TODO add test for bigfish.segmentation.match_nuc_cell
+# TODO add test for bigfish.segmentation.compute_mean_diameter
+# TODO add test for bigfish.segmentation.compute_mean_convexity_ratio
+# TODO add test for bigfish.segmentation.compute_surface_ratio
+# TODO add test for bigfish.segmentation.count_instances
+# TODO add test for bigfish.segmentation.resize_image
+# TODO add test for bigfish.segmentation.get_marge_padding
+# TODO add test for bigfish.segmentation.compute_image_standardization
diff --git a/bigfish/stack/tests/test_augmentation.py b/bigfish/stack/tests/test_augmentation.py
index 0dbb6a90..699c16af 100644
--- a/bigfish/stack/tests/test_augmentation.py
+++ b/bigfish/stack/tests/test_augmentation.py
@@ -140,3 +140,86 @@ def test_augment_2d_dtype(dtype):
                   [0, 0, 0, 0, 0]], dtype=dtype)
     y = stack.augment_2d(x)
     assert y.dtype == dtype
+
+
+def test_augment_2d_function():
+    operations = [_identity,
+                  _flip_h, _flip_v,
+                  _transpose, _transpose_inverse,
+                  _rotation_90, _rotation_180, _rotation_270]
+    bytecodes = []
+    for f in operations:
+        bytecode = f.__code__.co_code
+        bytecodes.append(bytecode)
+    f = stack.augment_2d_function()
+    assert f.__code__.co_code in bytecodes
+    f = stack.augment_2d_function(identity=True)
+    assert f.__code__.co_code == _identity.__code__.co_code
+
+
+def test_augment_8_times():
+    # one channel
+    expected_y_identity = x.copy()
+    expected_y_flip_h = np.array([[0, 0, 0, 0, 0],
+                                  [0, 1, 1, 1, 0],
+                                  [0, 1, 0, 0, 0],
+                                  [0, 1, 0, 0, 0],
+                                  [1, 0, 0, 0, 0]], dtype=np.uint8)
+    expected_y_flip_v = np.array([[0, 0, 0, 0, 1],
+                                  [0, 0, 0, 1, 0],
+                                  [0, 0, 0, 1, 0],
+                                  [0, 1, 1, 1, 0],
+                                  [0, 0, 0, 0, 0]], dtype=np.uint8)
+    expected_y_transpose = np.array([[1, 0, 0, 0, 0],
+                                     [0, 1, 1, 1, 0],
+                                     [0, 0, 0, 1, 0],
+                                     [0, 0, 0, 1, 0],
+                                     [0, 0, 0, 0, 0]], dtype=np.uint8)
+    expected_y_transpose_inverse = np.array([[0, 0, 0, 0, 0],
+                                             [0, 1, 0, 0, 0],
+                                             [0, 1, 0, 0, 0],
+                                             [0, 1, 1, 1, 0],
+                                             [0, 0, 0, 0, 1]], dtype=np.uint8)
+    expected_y_rotation_90 = np.array([[0, 0, 0, 0, 1],
+                                       [0, 1, 1, 1, 0],
+                                       [0, 1, 0, 0, 0],
+                                       [0, 1, 0, 0, 0],
+                                       [0, 0, 0, 0, 0]], dtype=np.uint8)
+    expected_y_rotation_180 = np.array([[0, 0, 0, 0, 0],
+                                        [0, 1, 1, 1, 0],
+                                        [0, 0, 0, 1, 0],
+                                        [0, 0, 0, 1, 0],
+                                        [0, 0, 0, 0, 1]], dtype=np.uint8)
+    expected_y_rotation_270 = np.array([[0, 0, 0, 0, 0],
+                                        [0, 0, 0, 1, 0],
+                                        [0, 0, 0, 1, 0],
+                                        [0, 1, 1, 1, 0],
+                                        [1, 0, 0, 0, 0]], dtype=np.uint8)
+    expected_y = [expected_y_identity,
+                  expected_y_flip_h, expected_y_flip_v,
+                  expected_y_transpose, expected_y_transpose_inverse,
+                  expected_y_rotation_90, expected_y_rotation_180,
+                  expected_y_rotation_270]
+    augmented_arrays = stack.augment_8_times(x)
+    assert isinstance(augmented_arrays, list)
+    assert len(augmented_arrays) == len(expected_y)
+    for a, b in zip(augmented_arrays, expected_y):
+        assert_array_equal(a, b)
+
+    # multichannel
+    xx = x[..., np.newaxis]
+    expected_yy = [y[..., np.newaxis] for y in expected_y]
+    augmented_arrays = stack.augment_8_times(xx)
+    assert isinstance(augmented_arrays, list)
+    assert len(augmented_arrays) == len(expected_yy)
+    for a, b in zip(augmented_arrays, expected_yy):
+        assert_array_equal(a, b)
+
+
+def test_augment_8_times_reversed():
+    y = stack.augment_8_times(x)
+    y_reversed = stack.augment_8_times_reversed(y)
+    assert isinstance(y_reversed, list)
+    assert len(y_reversed) == 8
+    for a in y_reversed:
+        assert_array_equal(a, x.copy())
diff --git a/bigfish/stack/tests/test_preprocess.py b/bigfish/stack/tests/test_preprocess.py
index 4ebae81b..17a4e9de 100644
--- a/bigfish/stack/tests/test_preprocess.py
+++ b/bigfish/stack/tests/test_preprocess.py
@@ -6,9 +6,7 @@
 Unitary tests for bigfish.stack.preprocess module.
 """
 
-import os
 import pytest
-import tempfile
 
 import numpy as np
 import bigfish.stack as stack
@@ -16,190 +14,6 @@
 from numpy.testing import assert_array_equal
 
 
-# ### Test stack building ###
-
-def test_build_stacks_from_recipe():
-    # build a temporary directory and save tensors inside
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        # field of view 1
-        test_nuc = np.zeros((8, 8, 8), dtype=np.uint8)
-        test_cyt = np.zeros((8, 8, 8), dtype=np.uint8)
-        test_rna = np.zeros((8, 8, 8), dtype=np.uint8)
-        path_nuc = os.path.join(tmp_dir, "test_nuc_1.tif")
-        path_cyt = os.path.join(tmp_dir, "test_cyt_1.tif")
-        path_rna = os.path.join(tmp_dir, "test_rna_1.tif")
-        stack.save_image(test_nuc, path_nuc)
-        stack.save_image(test_cyt, path_cyt)
-        stack.save_image(test_rna, path_rna)
-
-        # field of view 2
-        test_nuc = np.zeros((5, 5, 5), dtype=np.uint16)
-        test_cyt = np.zeros((5, 5, 5), dtype=np.uint16)
-        test_rna = np.zeros((5, 5, 5), dtype=np.uint16)
-        path_nuc = os.path.join(tmp_dir, "test_nuc_2.tif")
-        path_cyt = os.path.join(tmp_dir, "test_cyt_2.tif")
-        path_rna = os.path.join(tmp_dir, "test_rna_2.tif")
-        stack.save_image(test_nuc, path_nuc)
-        stack.save_image(test_cyt, path_cyt)
-        stack.save_image(test_rna, path_rna)
-
-        # define recipe to read tensors
-        recipe_1 ={"fov": ["1", "2"],
-                   "c": ["nuc", "cyt", "rna"],
-                   "opt": "test",
-                   "ext": "tif",
-                   "pattern": "opt_c_fov.ext"}
-
-        # build tensor without prior information
-        tensor = stack.build_stack(recipe_1, input_folder=tmp_dir)
-        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
-        assert_array_equal(tensor, expected_tensor)
-        assert tensor.dtype == np.uint8
-
-        # build tensor with prior information
-        tensor = stack.build_stack(recipe_1,
-                                   input_folder=tmp_dir,
-                                   input_dimension=3)
-        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
-        assert_array_equal(tensor, expected_tensor)
-        assert tensor.dtype == np.uint8
-
-        # build tensors with different fields of view
-        tensor = stack.build_stack(recipe_1,
-                                   input_folder=tmp_dir,
-                                   input_dimension=3,
-                                   i_fov=0)
-        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
-        assert_array_equal(tensor, expected_tensor)
-        assert tensor.dtype == np.uint8
-        tensor = stack.build_stack(recipe_1,
-                                   input_folder=tmp_dir,
-                                   input_dimension=3,
-                                   i_fov=1)
-        expected_tensor = np.zeros((1, 3, 5, 5, 5), dtype=np.uint16)
-        assert_array_equal(tensor, expected_tensor)
-        assert tensor.dtype == np.uint16
-
-        # wrong recipe
-        recipe_wrong = {"fov": "test",
-                        "c": ["nuc", "cyt", "rna"],
-                        "ext": "tif",
-                        "pattern": "fov_c.ext"}
-        with pytest.raises(FileNotFoundError):
-            stack.build_stack(recipe_wrong,
-                              input_folder=tmp_dir,
-                              input_dimension=3)
-
-        # wrong path
-        with pytest.raises(FileNotFoundError):
-            stack.build_stack(recipe_1,
-                              input_folder="/foo/bar",
-                              input_dimension=3)
-
-
-def test_build_stacks_from_datamap():
-    # build a temporary directory and save tensors inside
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        # field of view 1
-        test_nuc = np.zeros((8, 8, 8), dtype=np.uint8)
-        test_cyt = np.zeros((8, 8, 8), dtype=np.uint8)
-        test_rna = np.zeros((8, 8, 8), dtype=np.uint8)
-        path_nuc = os.path.join(tmp_dir, "test_nuc_1.tif")
-        path_cyt = os.path.join(tmp_dir, "test_cyt_1.tif")
-        path_rna = os.path.join(tmp_dir, "test_rna_1.tif")
-        stack.save_image(test_nuc, path_nuc)
-        stack.save_image(test_cyt, path_cyt)
-        stack.save_image(test_rna, path_rna)
-
-        # field of view 2
-        test_nuc = np.zeros((5, 5, 5), dtype=np.uint16)
-        test_cyt = np.zeros((5, 5, 5), dtype=np.uint16)
-        test_rna = np.zeros((5, 5, 5), dtype=np.uint16)
-        path_nuc = os.path.join(tmp_dir, "test_nuc_2.tif")
-        path_cyt = os.path.join(tmp_dir, "test_cyt_2.tif")
-        path_rna = os.path.join(tmp_dir, "test_rna_2.tif")
-        stack.save_image(test_nuc, path_nuc)
-        stack.save_image(test_cyt, path_cyt)
-        stack.save_image(test_rna, path_rna)
-
-        # define datamap to read  tensors
-        recipe_1 = {"fov": ["1", "2"],
-                    "c": ["nuc", "cyt", "rna"],
-                    "opt": "test",
-                    "ext": "tif",
-                    "pattern": "opt_c_fov.ext"}
-        recipe_2 = {"fov": "2",
-                    "c": ["nuc", "cyt", "rna"],
-                    "opt": "test",
-                    "ext": "tif",
-                    "pattern": "opt_c_fov.ext"}
-        data_map = [(recipe_1, tmp_dir), (recipe_2, tmp_dir)]
-
-        # build stacks from generator
-        generator = stack.build_stacks(data_map, input_dimension=3)
-        expected_tensors = [np.zeros((1, 3, 8, 8, 8), dtype=np.uint8),
-                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16),
-                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16)]
-        for i, tensor in enumerate(generator):
-            expected_tensor = expected_tensors[i]
-            assert_array_equal(tensor, expected_tensor)
-            assert tensor.dtype == expected_tensor.dtype
-
-        # build stacks from generator with metadata
-        generator = stack.build_stacks(data_map,
-                                       input_dimension=3,
-                                       return_origin=True)
-        expected_tensors = [np.zeros((1, 3, 8, 8, 8), dtype=np.uint8),
-                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16),
-                            np.zeros((1, 3, 5, 5, 5), dtype=np.uint16)]
-        expected_recipes = [recipe_1, recipe_1, recipe_2]
-        expected_i_fov = [0, 1, 0]
-        for i, (tensor, input_folder, recipe, i_fov) in enumerate(generator):
-            expected_tensor = expected_tensors[i]
-            assert_array_equal(tensor, expected_tensor)
-            assert tensor.dtype == expected_tensor.dtype
-            assert input_folder == tmp_dir
-            assert recipe == expected_recipes[i]
-            assert i_fov == expected_i_fov[i]
-
-        # wrong datamap
-        data_map = [(recipe_1, 3), (recipe_2, tmp_dir)]
-        generator = stack.build_stacks(data_map, input_dimension=3)
-        with pytest.raises(TypeError):
-            next(generator)
-        data_map = [(recipe_1, "foo/bar"), (recipe_2, tmp_dir)]
-        generator = stack.build_stacks(data_map, input_dimension=3)
-        with pytest.raises(NotADirectoryError):
-            next(generator)
-
-
-def test_build_stack_from_path():
-    # build a temporary directory and save tensors inside
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        # field of view
-        test_nuc = np.zeros((8, 8, 8), dtype=np.uint8)
-        test_cyt = np.zeros((8, 8, 8), dtype=np.uint8)
-        test_rna = np.zeros((8, 8, 8), dtype=np.uint8)
-        path_nuc = os.path.join(tmp_dir, "test_nuc.tif")
-        path_cyt = os.path.join(tmp_dir, "test_cyt.tif")
-        path_rna = os.path.join(tmp_dir, "test_rna.tif")
-        stack.save_image(test_nuc, path_nuc)
-        stack.save_image(test_cyt, path_cyt)
-        stack.save_image(test_rna, path_rna)
-
-        # build tensor from paths
-        paths = [path_nuc, path_cyt, path_rna]
-        tensor = stack.build_stack_no_recipe(paths, input_dimension=3)
-        expected_tensor = np.zeros((1, 3, 8, 8, 8), dtype=np.uint8)
-        assert_array_equal(tensor, expected_tensor)
-        assert tensor.dtype == np.uint8
-
-        # wrong paths
-        paths = [path_nuc, path_cyt, "/foo/bar/test_rna.tif"]
-        with pytest.raises(FileNotFoundError):
-            stack.build_stack_no_recipe(paths, input_dimension=3)
-
-
 # ### Test normalization ###
 
 @pytest.mark.parametrize("dtype", [
diff --git a/bigfish/stack/tests/test_projection.py b/bigfish/stack/tests/test_projection.py
index 2173d16b..8f4d16c7 100644
--- a/bigfish/stack/tests/test_projection.py
+++ b/bigfish/stack/tests/test_projection.py
@@ -16,6 +16,10 @@
 from numpy.testing import assert_array_equal
 
 
+# TODO test bigfish.stack.focus_projection
+# TODO test bigfish.stack.in_focus_selection
+# TODO test bigfish.stack.get_in_focus_indices
+
 # toy images
 x = np.array(
     [[[1, 0, 0, 0, 0],
@@ -179,8 +183,3 @@ def test_one_hot_3d(dtype):
     expected_one_hot = expected_one_hot.astype(bool)
     assert_array_equal(one_hot, expected_one_hot)
     assert one_hot.dtype == bool
-
-# TODO remove test bigfish.stack.test_one_hot_3d
-# TODO test bigfish.stack.focus_projection
-# TODO test bigfish.stack.in_focus_selection
-# TODO test bigfish.stack.get_in_focus_indices
diff --git a/bigfish/stack/tests/test_quality.py b/bigfish/stack/tests/test_quality.py
new file mode 100644
index 00000000..47d58f64
--- /dev/null
+++ b/bigfish/stack/tests/test_quality.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Author: Arthur Imbert <arthur.imbert.pro@gmail.com>
+# License: BSD 3 clause
+
+"""
+Unitary tests for bigfish.stack.quality module.
+"""
+
+
+# TODO add test for bigfish.stack.compute_focus
diff --git a/bigfish/stack/tests/test_utils.py b/bigfish/stack/tests/test_utils.py
index 60b0ec65..736e80b9 100644
--- a/bigfish/stack/tests/test_utils.py
+++ b/bigfish/stack/tests/test_utils.py
@@ -6,24 +6,20 @@
 Unitary tests for bigfish.stack.utils module.
 """
 
-import os
 import pytest
-import tempfile
 
 import bigfish.stack as stack
 
 import numpy as np
 import pandas as pd
 
-from bigfish.stack.utils import fit_recipe
-from bigfish.stack.utils import get_path_from_recipe
-from bigfish.stack.utils import get_nb_element_per_dimension
-from bigfish.stack.utils import count_nb_fov
-
 
 # TODO add test for bigfish.stack.load_and_save_url
 # TODO add test for bigfish.stack.check_hash
 # TODO add test for bigfish.stack.compute_hash
+# TODO add test for bigfish.stack.check_input_data
+# TODO add test for bigfish.stack.moving_average
+# TODO add test for bigfish.stack.centered_moving_average
 
 # ### Test sanity check functions ###
 
@@ -140,209 +136,6 @@ def test_check_range_value():
         stack.check_range_value(a, min_=None, max_=8)
 
 
-# ### Test recipes ###
-
-def test_check_recipe():
-    # build a temporary directory with two files
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        path = os.path.join(tmp_dir, "experience_1_dapi_fov_1.tif")
-        with open(path, 'w') as f:
-            f.write("dapi file")
-        path = os.path.join(tmp_dir, "experience_1_smfish_fov_1.tif")
-        with open(path, 'w') as f:
-            f.write("smFISH file")
-
-        # test the consistency of the check function when it should work
-        good_recipe_1 = {"fov": "fov_1",
-                         "c": ["dapi", "smfish"],
-                         "opt": "experience_1",
-                         "ext": "tif",
-                         "pattern": "opt_c_fov.ext"}
-        assert stack.check_recipe(good_recipe_1, data_directory=None)
-        assert stack.check_recipe(good_recipe_1, data_directory=tmp_dir)
-
-        # case with a good recipe but when a file is missing
-        good_recipe_2 = {"fov": "fov_1",
-                         "c": ["dapi", "smfish", "cellmask"],
-                         "opt": "experience_1",
-                         "ext": "tif",
-                         "pattern": "opt_c_fov.ext"}
-        assert stack.check_recipe(good_recipe_2, data_directory=None)
-        with pytest.raises(FileNotFoundError):
-            stack.check_recipe(good_recipe_2, data_directory=tmp_dir)
-
-        # cases without a 'pattern' key with a string value
-        bad_recipe_1 = {"fov": "fov_1",
-                        "c": ["dapi", "smfish"],
-                        "opt": "experience_1",
-                        "ext": "tif"}
-        bad_recipe_2 = {"fov": "fov_1",
-                        "c": ["dapi", "smfish"],
-                        "opt": "experience_1",
-                        "ext": "tif",
-                        "pattern": ["opt_c_fov.ext"]}
-        with pytest.raises(KeyError):
-            stack.check_recipe(bad_recipe_1, data_directory=None)
-        with pytest.raises(TypeError):
-            stack.check_recipe(bad_recipe_2, data_directory=None)
-
-        # case with a wrong pattern (repetitive key)
-        bad_recipe_3 = {"fov": "fov_1",
-                        "c": ["dapi", "smfish"],
-                        "opt": "experience_1",
-                        "ext": "tif",
-                        "pattern": "opt_c_fov_fov.ext"}
-        with pytest.raises(ValueError):
-            stack.check_recipe(bad_recipe_3, data_directory=None)
-
-        # case with wrong key or value
-        bad_recipe_4 = {"fov": "fov_1",
-                        "channel": ["dapi", "smfish"],
-                        "optional": "experience_1",
-                        "extension": "tif",
-                        "pattern": "opt_c_fov.ext"}
-        bad_recipe_5 = {"fov": "fov_1",
-                        "c": ["dapi", "smfish"],
-                        "opt": 1,
-                        "ext": "tif",
-                        "pattern": "opt_c_fov.ext"}
-        with pytest.raises(KeyError):
-            stack.check_recipe(bad_recipe_4, data_directory=None)
-        with pytest.raises(TypeError):
-            stack.check_recipe(bad_recipe_5, data_directory=None)
-
-
-def test_fit_recipe():
-    # build a recipe to fit
-    good_recipe = {"fov": "fov_1",
-                   "c": ["dapi", "smfish"],
-                   "opt": "experience_1",
-                   "ext": "tif",
-                   "pattern": "opt_c_fov.ext"}
-
-    # fit recipe
-    new_recipe = fit_recipe(good_recipe)
-
-    # all keys should be initialized in the new recipe, with a list or a string
-    for key in ['fov', 'r', 'c', 'z']:
-        assert key in new_recipe
-        assert isinstance(new_recipe[key], list)
-    for key in ['ext', 'opt']:
-        assert key in new_recipe
-        assert isinstance(new_recipe[key], str)
-    assert 'pattern' in new_recipe
-    assert isinstance(new_recipe['pattern'], str)
-
-    # test that fitting an already fitted recipe does not change anything
-    new_recip_bis = fit_recipe(new_recipe)
-    assert new_recip_bis == new_recipe
-
-
-def test_path_from_recipe():
-    # build a temporary directory with one file
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        path = os.path.join(tmp_dir, "experience_1_dapi_fov_1.tif")
-        with open(path, 'w') as f:
-            f.write("dapi file")
-
-        # build a recipe to read the file
-        good_recipe = {"fov": "fov_1",
-                       "c": "dapi",
-                       "opt": "experience_1",
-                       "ext": "tif",
-                       "pattern": "opt_c_fov.ext"}
-
-        # test the path
-        path_dapi = get_path_from_recipe(good_recipe, tmp_dir, c=0)
-        assert os.path.isfile(path_dapi)
-
-
-def test_element_per_dimension():
-    # build a recipe to test
-    good_recipe = {"fov": "fov_1",
-                   "c": ["dapi", "smfish"],
-                   "opt": "experience_1",
-                   "ext": "tif",
-                   "pattern": "opt_c_fov.ext"}
-
-    # test the number of elements to be stacked
-    nb_r, nb_c, nb_z = get_nb_element_per_dimension(good_recipe)
-    assert nb_r == 1
-    assert nb_c == 2
-    assert nb_z == 1
-
-
-def test_nb_fov():
-    # case when 'fov' key is a string
-    good_recipe_1 = {"fov": "fov_1",
-                     "c": ["dapi", "smfish"],
-                     "opt": "experience_1",
-                     "ext": "tif",
-                     "pattern": "opt_c_fov.ext"}
-    nb_fov = count_nb_fov(good_recipe_1)
-    assert nb_fov == 1
-
-    # case when 'fov' key is a list
-    good_recipe_2 = {"fov": ["fov_1", "fov_2"],
-                     "c": ["dapi", "smfish"],
-                     "opt": "experience_1",
-                     "ext": "tif",
-                     "pattern": "opt_c_fov.ext"}
-    nb_fov = count_nb_fov(good_recipe_2)
-    assert nb_fov == 2
-
-    # case when 'fov' key does not exist
-    good_recipe_3 = {"c": ["dapi", "smfish"],
-                     "opt": "experience_1",
-                     "ext": "tif",
-                     "pattern": "opt_c_fov.ext"}
-    nb_fov = count_nb_fov(good_recipe_3)
-    assert nb_fov == 1
-
-    # case when the 'fov' key is not a string or a list
-    with pytest.raises(TypeError):
-        bad_recipe = {"fov": 1,
-                      "c": ["dapi", "smfish"],
-                      "opt": "experience_1",
-                      "ext": "tif",
-                      "pattern": "opt_c_fov.ext"}
-        count_nb_fov(bad_recipe)
-
-
-def test_check_datamap():
-    # build a temporary directory with two files
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        path = os.path.join(tmp_dir, "experience_1_dapi_fov_1.tif")
-        with open(path, 'w') as f:
-            f.write("dapi file")
-        path = os.path.join(tmp_dir, "experience_1_smfish_fov_1.tif")
-        with open(path, 'w') as f:
-            f.write("smFISH file")
-
-        # test the consistency of the check function
-        recipe = {"fov": "fov_1",
-                  "c": ["dapi", "smfish"],
-                  "opt": "experience_1",
-                  "ext": "tif",
-                  "pattern": "opt_c_fov.ext"}
-        datamap = [(recipe, tmp_dir)]
-        assert stack.check_datamap(datamap)
-        datamap = [[recipe, tmp_dir]]
-        assert stack.check_datamap(datamap)
-        datamap = [(None, tmp_dir)]
-        with pytest.raises(TypeError):
-            stack.check_datamap(datamap)
-        datamap = [(recipe, 3)]
-        with pytest.raises(TypeError):
-            stack.check_datamap(datamap)
-        datamap = [(recipe, "/foo/bar")]
-        with pytest.raises(NotADirectoryError):
-            stack.check_datamap(datamap)
-        datamap = [(recipe, tmp_dir, None)]
-        with pytest.raises(ValueError):
-            stack.check_datamap(datamap)
-
-
 # ### Constants ###
 
 def test_margin_value():