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/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 +# 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 +# License: BSD 3 clause + +""" +Unitary tests for bigfish.classification.input_preparation module. +""" + +# TODO add test bigfish.classification.prepare_extracted_data 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 -# 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/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 +# 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/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 +# 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 +# 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 +# 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 +# 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/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 +# 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 +# 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 +# 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/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 +# 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/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 +# 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/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 -# 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/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 +# 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(): 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 `_. +See an example of application `here `_. .. 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 `_. +See an example of application `here `_. .. 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 `_. +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 `_. +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 `_. +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 `_ or `conda +`virtual `_ or `conda `_ 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 `_ to install +Use the package manager `pip `_ 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 `_ 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 `_: +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 `_. 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`: 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 -`_ on Github. \ No newline at end of file +`_ 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 `_. +See an example of application `here `_. .. 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 `_. +See an example of application `here `_. .. 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 `_. +See an example of application `here `_. .. 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