diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad3b69c7..b368ba0c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ **Note**: Numbers like (\#123) point to closed Pull Requests on the fractal-tasks-core repository. +# 0.14.2 +* Add custom normalization options to the Cellpose task (#650) +* Add more options to the Cellpose task to control model behavior (#650) +* For Cellpose task, switch to using Enums for model_type (see issue #401) + # 0.14.1 * Fix bug in `cellpose_segmentation` upon using masked loading and setting `channel2` (\#639). Thanks [@FranziskaMoos-FMI](https://github.com/FranziskaMoos-FMI) and [@enricotagliavini](https://github.com/enricotagliavini). diff --git a/examples/06_cellpose_dev_example/fetch_test_data_from_zenodo.sh b/examples/06_cellpose_dev_example/fetch_test_data_from_zenodo.sh new file mode 100755 index 000000000..fcda5db76 --- /dev/null +++ b/examples/06_cellpose_dev_example/fetch_test_data_from_zenodo.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +DOI="10.5281/zenodo.7059515" +CLEAN_DOI=${DOI/\//_} +zenodo_get $DOI -o ../images/${CLEAN_DOI} diff --git a/examples/06_cellpose_dev_example/run_example_cellpose_normalization.py b/examples/06_cellpose_dev_example/run_example_cellpose_normalization.py new file mode 100644 index 000000000..092f48e6e --- /dev/null +++ b/examples/06_cellpose_dev_example/run_example_cellpose_normalization.py @@ -0,0 +1,139 @@ +""" +Copyright 2022 (C) Friedrich Miescher Institute for Biomedical Research and +University of Zurich + +Original authors: +Marco Franzon +Tommaso Comparin + +This file is part of Fractal and was originally developed by eXact lab S.r.l. + under contract with Liberali Lab from the Friedrich Miescher +Institute for Biomedical Research and Pelkmans Lab from the University of +Zurich. +""" +import os + +from devtools import debug + +from fractal_tasks_core.channels import OmeroChannel +from fractal_tasks_core.channels import Window +from fractal_tasks_core.tasks.cellpose_segmentation import ( + cellpose_segmentation, +) +from fractal_tasks_core.tasks.cellpose_transforms import ( + CellposeCustomNormalizer, +) +from fractal_tasks_core.tasks.copy_ome_zarr import copy_ome_zarr +from fractal_tasks_core.tasks.create_ome_zarr import create_ome_zarr +from fractal_tasks_core.tasks.maximum_intensity_projection import ( + maximum_intensity_projection, +) +from fractal_tasks_core.tasks.yokogawa_to_ome_zarr import yokogawa_to_ome_zarr + +allowed_channels = [ + OmeroChannel( + label="DAPI", + wavelength_id="A01_C01", + color="00FFFF", + window=Window(start=110, end=700), + ) +] + + +num_levels = 6 +coarsening_xy = 2 + + +# Init +img_path = "../images/10.5281_zenodo.7059515/" +if not os.path.isdir(img_path): + raise FileNotFoundError( + f"{img_path} is missing," + " try running ./fetch_test_data_from_zenodo.sh" + ) +zarr_path = "tmp_out_normalize/" +metadata: dict = {} + +# Create zarr structure +metadata_update = create_ome_zarr( + input_paths=[img_path], + output_path=zarr_path, + metadata=metadata, + image_extension="png", + allowed_channels=allowed_channels, + num_levels=num_levels, + coarsening_xy=coarsening_xy, + overwrite=True, +) +metadata.update(metadata_update) +debug(metadata) + +# Yokogawa to zarr +for component in metadata["image"]: + yokogawa_to_ome_zarr( + input_paths=[zarr_path], + output_path=zarr_path, + metadata=metadata, + component=component, + ) +debug(metadata) + + +# Copy zarr structure +metadata_update = copy_ome_zarr( + input_paths=[zarr_path], + output_path=zarr_path, + metadata=metadata, + suffix="mip", + overwrite=True, +) +metadata.update(metadata_update) + +# Make MIP +for component in metadata["image"]: + metadata_update = maximum_intensity_projection( + input_paths=[zarr_path], + output_path=zarr_path, + metadata=metadata, + component=component, + ) + metadata.update(metadata_update) + + +normalize = CellposeCustomNormalizer(default_normalize=True) +normalize = CellposeCustomNormalizer( + default_normalize=False, lower_percentile=1.0, upper_percentile=99.0 +) +# normalize=CellposeCustomNormalizer( +# default_normalize=True, +# lower_percentile=1.0, +# upper_percentile=99.0 +# ) # ValueError +# normalize=CellposeCustomNormalizer( +# default_normalize=False, +# lower_percentile=1.0, +# upper_percentile=99.0, +# lower_bound=100, +# upper_bound=500 +# ) # ValueError +normalize = CellposeCustomNormalizer( + default_normalize=False, lower_bound=100, upper_bound=500 +) + + +# Per-FOV labeling +for component in metadata["image"]: + cellpose_segmentation( + input_paths=[zarr_path], + output_path=zarr_path, + metadata=metadata, + component=component, + channel=dict(wavelength_id="A01_C01"), + level=1, + relabeling=True, + diameter_level0=40.0, + model_type="cyto2", + normalize=normalize, + ) + metadata.update(metadata_update) +debug(metadata) diff --git a/fractal_tasks_core/__FRACTAL_MANIFEST__.json b/fractal_tasks_core/__FRACTAL_MANIFEST__.json index 6428adb2f..e18d8518e 100644 --- a/fractal_tasks_core/__FRACTAL_MANIFEST__.json +++ b/fractal_tasks_core/__FRACTAL_MANIFEST__.json @@ -439,6 +439,23 @@ "model_type": { "title": "Model Type", "default": "cyto2", + "enum": [ + "cyto", + "nuclei", + "tissuenet", + "livecell", + "cyto2", + "general", + "CP", + "CPx", + "TN1", + "TN2", + "TN3", + "LC1", + "LC2", + "LC3", + "LC4" + ], "type": "string", "description": "Parameter of `CellposeModel` class. Defines which model should be used. Typical choices are `nuclei`, `cyto`, `cyto2`, etc." }, @@ -459,6 +476,22 @@ "type": "number", "description": "Parameter of `CellposeModel.eval` method. Valid values between 0.0 and 1.0. From Cellpose documentation: \"Increase this threshold if cellpose is not returning as many ROIs as you\u2019d expect. Similarly, decrease this threshold if cellpose is returning too many ill-shaped ROIs.\"" }, + "normalize": { + "title": "Normalize", + "default": { + "type": "default", + "lower_percentile": null, + "upper_percentile": null, + "lower_bound": null, + "upper_bound": null + }, + "allOf": [ + { + "$ref": "#/definitions/CellposeCustomNormalizer" + } + ], + "description": "By default, data is normalized so 0.0=1st percentile and 1.0=99th percentile of image intensities in each channel. This automatic normalization can lead to issues when the image to be segmented is very sparse. You can turn off the default rescaling. With the \"custom\" option, you can either provide your own rescaling percentiles or fixed rescaling upper and lower bound integers." + }, "anisotropy": { "title": "Anisotropy", "type": "number", @@ -488,6 +521,48 @@ "type": "boolean", "description": "If `False`, always use the CPU; if `True`, use the GPU if possible (as defined in `cellpose.core.use_gpu()`) and fall-back to the CPU otherwise." }, + "batch_size": { + "title": "Batch Size", + "default": 8, + "type": "integer", + "description": "number of 224x224 patches to run simultaneously on the GPU (can make smaller or bigger depending on GPU memory usage)" + }, + "invert": { + "title": "Invert", + "default": false, + "type": "boolean", + "description": "invert image pixel intensity before running network (if True, image is also normalized)" + }, + "tile": { + "title": "Tile", + "default": true, + "type": "boolean", + "description": "tiles image to ensure GPU/CPU memory usage limited (recommended)" + }, + "tile_overlap": { + "title": "Tile Overlap", + "default": 0.1, + "type": "number", + "description": "fraction of overlap of tiles when computing flows" + }, + "resample": { + "title": "Resample", + "default": true, + "type": "boolean", + "description": "run dynamics at original image size (will be slower but create more accurate boundaries)" + }, + "interp": { + "title": "Interp", + "default": true, + "type": "boolean", + "description": "interpolate during 2D dynamics (not available in 3D) (in previous versions it was False, now it defaults to True)" + }, + "stitch_threshold": { + "title": "Stitch Threshold", + "default": 0.0, + "type": "number", + "description": "if stitch_threshold>0.0 and not do_3D and equal image sizes, masks are stitched in 3D to return volume segmentation" + }, "overwrite": { "title": "Overwrite", "default": true, @@ -521,6 +596,48 @@ "description": "Name of the channel." } } + }, + "CellposeCustomNormalizer": { + "title": "CellposeCustomNormalizer", + "description": "Validator to handle different normalization scenarios for Cellpose models", + "type": "object", + "properties": { + "type": { + "title": "Type", + "default": "default", + "enum": [ + "default", + "custom", + "no_normalization" + ], + "type": "string", + "description": "One of `default` (Cellpose default normalization), `custom` (using the other custom parameters) or `no_normalization`." + }, + "lower_percentile": { + "title": "Lower Percentile", + "minimum": 0, + "maximum": 100, + "type": "number", + "description": "Specify a custom lower-bound percentile for rescaling as a float value between 0 and 100. Set to 1 to run the same as default). You can only specify percentiles or bounds, not both." + }, + "upper_percentile": { + "title": "Upper Percentile", + "minimum": 0, + "maximum": 100, + "type": "number", + "description": "Specify a custom upper-bound percentile for rescaling as a float value between 0 and 100. Set to 99 to run the same as default, set to e.g. 99.99 if the default rescaling was too harsh. You can only specify percentiles or bounds, not both." + }, + "lower_bound": { + "title": "Lower Bound", + "type": "integer", + "description": "Explicit lower bound value to rescale the image at. Needs to be an integer, e.g. 100. You can only specify percentiles or bounds, not both." + }, + "upper_bound": { + "title": "Upper Bound", + "type": "integer", + "description": "Explicit upper bound value to rescale the image at. Needs to be an integer, e.g. 2000. You can only specify percentiles or bounds, not both." + } + } } } }, diff --git a/fractal_tasks_core/dev/lib_args_schemas.py b/fractal_tasks_core/dev/lib_args_schemas.py index bad4b4231..3b98aa539 100644 --- a/fractal_tasks_core/dev/lib_args_schemas.py +++ b/fractal_tasks_core/dev/lib_args_schemas.py @@ -60,6 +60,11 @@ "tasks/napari_workflows_wrapper_models.py", "NapariWorkflowsOutput", ), + ( + "fractal_tasks_core", + "tasks/cellpose_transforms.py", + "CellposeCustomNormalizer", + ), ] diff --git a/fractal_tasks_core/tasks/cellpose_segmentation.py b/fractal_tasks_core/tasks/cellpose_segmentation.py index 562e1bc57..1974a5b13 100644 --- a/fractal_tasks_core/tasks/cellpose_segmentation.py +++ b/fractal_tasks_core/tasks/cellpose_segmentation.py @@ -18,6 +18,7 @@ import time from pathlib import Path from typing import Any +from typing import Literal from typing import Optional from typing import Sequence @@ -54,6 +55,10 @@ from fractal_tasks_core.roi import is_ROI_table_valid from fractal_tasks_core.roi import load_region from fractal_tasks_core.tables import write_table +from fractal_tasks_core.tasks.cellpose_transforms import ( + CellposeCustomNormalizer, +) +from fractal_tasks_core.tasks.cellpose_transforms import normalized_img from fractal_tasks_core.utils import rescale_datasets logger = logging.getLogger(__name__) @@ -70,10 +75,18 @@ def segment_ROI( diameter: float = 30.0, cellprob_threshold: float = 0.0, flow_threshold: float = 0.4, + normalize: CellposeCustomNormalizer = CellposeCustomNormalizer(), label_dtype: Optional[np.dtype] = None, augment: bool = False, net_avg: bool = False, min_size: int = 15, + batch_size: int = 8, + invert: bool = False, + tile: bool = True, + tile_overlap: float = 0.1, + resample: bool = True, + interp: bool = True, + stitch_threshold: float = 0.0, ) -> np.ndarray: """ Internal function that runs Cellpose segmentation for a single ROI. @@ -92,6 +105,10 @@ def segment_ROI( diameter: Expected object diameter in pixels for cellpose. cellprob_threshold: Cellpose model parameter. flow_threshold: Cellpose model parameter. + normalize: normalize data so 0.0=1st percentile and 1.0=99th + percentile of image intensities in each channel. This automatic + normalization can lead to issues when the image to be segmented + is very sparse. label_dtype: Label images are cast into this `np.dtype`. augment: Whether to use cellpose augmentation to tile images with overlap. @@ -99,6 +116,18 @@ def segment_ROI( networks (useful for `nuclei`, `cyto` and `cyto2`, not sure it works for the others). min_size: Minimum size of the segmented objects. + batch_size: number of 224x224 patches to run simultaneously on the GPU + (can make smaller or bigger depending on GPU memory usage) + invert: invert image pixel intensity before running network (if True, + image is also normalized) + tile: tiles image to ensure GPU/CPU memory usage limited (recommended) + tile_overlap: fraction of overlap of tiles when computing flows + resample: run dynamics at original image size (will be slower but + create more accurate boundaries) + interp: interpolate during 2D dynamics (not available in 3D) + (in previous versions it was False, now it defaults to True) + stitch_threshold: if stitch_threshold>0.0 and not do_3D and equal + image sizes, masks are stitched in 3D to return volume segmentation """ # Write some debugging info @@ -108,9 +137,20 @@ def segment_ROI( f" {do_3D=} |" f" {model.diam_mean=} |" f" {diameter=} |" - f" {flow_threshold=}" + f" {flow_threshold=} |" + f" {normalize.type=}" ) + # Optionally perform custom normalization + if normalize.type == "custom": + x = normalized_img( + x, + lower_p=normalize.lower_percentile, + upper_p=normalize.upper_percentile, + lower_bound=normalize.lower_bound, + upper_bound=normalize.upper_bound, + ) + # Actual labeling t0 = time.perf_counter() mask, _, _ = model.eval( @@ -123,7 +163,15 @@ def segment_ROI( anisotropy=anisotropy, cellprob_threshold=cellprob_threshold, flow_threshold=flow_threshold, + normalize=normalize.cellpose_normalize, min_size=min_size, + batch_size=batch_size, + invert=invert, + tile=tile, + tile_overlap=tile_overlap, + resample=resample, + interp=interp, + stitch_threshold=stitch_threshold, ) if mask.ndim == 2: @@ -165,15 +213,25 @@ def cellpose_segmentation( relabeling: bool = True, # Cellpose-related arguments diameter_level0: float = 30.0, - model_type: str = "cyto2", + # https://github.com/fractal-analytics-platform/fractal-tasks-core/issues/401 # noqa E501 + model_type: Literal[tuple(models.MODEL_NAMES)] = "cyto2", pretrained_model: Optional[str] = None, cellprob_threshold: float = 0.0, flow_threshold: float = 0.4, + normalize: CellposeCustomNormalizer = CellposeCustomNormalizer(), anisotropy: Optional[float] = None, min_size: int = 15, augment: bool = False, net_avg: bool = False, use_gpu: bool = True, + batch_size: int = 8, + invert: bool = False, + tile: bool = True, + tile_overlap: float = 0.1, + resample: bool = True, + interp: bool = True, + stitch_threshold: float = 0.0, + # Overwrite option overwrite: bool = True, ) -> dict[str, Any]: """ @@ -235,6 +293,13 @@ def cellpose_segmentation( this threshold if cellpose is not returning as many ROIs as you’d expect. Similarly, decrease this threshold if cellpose is returning too many ill-shaped ROIs." + normalize: By default, data is normalized so 0.0=1st percentile and + 1.0=99th percentile of image intensities in each channel. + This automatic normalization can lead to issues when the image to + be segmented is very sparse. You can turn off the default + rescaling. With the "custom" option, you can either provide your + own rescaling percentiles or fixed rescaling upper and lower + bound integers. anisotropy: Ratio of the pixel sizes along Z and XY axis (ignored if the image is not three-dimensional). If `None`, it is inferred from the OME-NGFF metadata. @@ -249,6 +314,18 @@ def cellpose_segmentation( use_gpu: If `False`, always use the CPU; if `True`, use the GPU if possible (as defined in `cellpose.core.use_gpu()`) and fall-back to the CPU otherwise. + batch_size: number of 224x224 patches to run simultaneously on the GPU + (can make smaller or bigger depending on GPU memory usage) + invert: invert image pixel intensity before running network (if True, + image is also normalized) + tile: tiles image to ensure GPU/CPU memory usage limited (recommended) + tile_overlap: fraction of overlap of tiles when computing flows + resample: run dynamics at original image size (will be slower but + create more accurate boundaries) + interp: interpolate during 2D dynamics (not available in 3D) + (in previous versions it was False, now it defaults to True) + stitch_threshold: if stitch_threshold>0.0 and not do_3D and equal + image sizes, masks are stitched in 3D to return volume segmentation overwrite: If `True`, overwrite the task output. """ @@ -260,10 +337,7 @@ def cellpose_segmentation( logger.info(f"{zarrurl=}") # Preliminary checks on Cellpose model - if pretrained_model is None: - if model_type not in models.MODEL_NAMES: - raise ValueError(f"ERROR model_type={model_type} is not allowed.") - else: + if pretrained_model: if not os.path.exists(pretrained_model): raise ValueError(f"{pretrained_model=} does not exist.") @@ -529,9 +603,17 @@ def cellpose_segmentation( diameter=diameter_level0 / coarsening_xy**level, cellprob_threshold=cellprob_threshold, flow_threshold=flow_threshold, + normalize=normalize, min_size=min_size, augment=augment, net_avg=net_avg, + batch_size=batch_size, + invert=invert, + tile=tile, + tile_overlap=tile_overlap, + resample=resample, + interp=interp, + stitch_threshold=stitch_threshold, ) # Prepare keyword arguments for preprocessing function diff --git a/fractal_tasks_core/tasks/cellpose_transforms.py b/fractal_tasks_core/tasks/cellpose_transforms.py new file mode 100644 index 000000000..f74ed0ef7 --- /dev/null +++ b/fractal_tasks_core/tasks/cellpose_transforms.py @@ -0,0 +1,243 @@ +# Copyright 2023 (C) Friedrich Miescher Institute for Biomedical Research and +# University of Zurich +# +# Original authors: +# Joel Lüthi +# +# This file is part of Fractal and was originally developed by eXact lab S.r.l. +# under contract with Liberali Lab from the Friedrich Miescher +# Institute for Biomedical Research and Pelkmans Lab from the University of +# Zurich. +""" +Helper functions for image normalization in +""" +import logging +from typing import Literal +from typing import Optional + +import numpy as np +from pydantic import BaseModel +from pydantic import Field +from pydantic import root_validator + + +logger = logging.getLogger(__name__) + + +class CellposeCustomNormalizer(BaseModel): + """ + Validator to handle different normalization scenarios for Cellpose models + + If `type="default"`, then Cellpose default normalization is + used and no other parameters can be specified. + If `type="no_normalization"`, then no normalization is used and no + other parameters can be specified. + If `type="custom"`, then either percentiles or explicit integer + bounds can be applied. + + Attributes: + type: + One of `default` (Cellpose default normalization), `custom` + (using the other custom parameters) or `no_normalization`. + lower_percentile: Specify a custom lower-bound percentile for rescaling + as a float value between 0 and 100. Set to 1 to run the same as + default). You can only specify percentiles or bounds, not both. + upper_percentile: Specify a custom upper-bound percentile for rescaling + as a float value between 0 and 100. Set to 99 to run the same as + default, set to e.g. 99.99 if the default rescaling was too harsh. + You can only specify percentiles or bounds, not both. + lower_bound: Explicit lower bound value to rescale the image at. + Needs to be an integer, e.g. 100. + You can only specify percentiles or bounds, not both. + upper_bound: Explicit upper bound value to rescale the image at. + Needs to be an integer, e.g. 2000. + You can only specify percentiles or bounds, not both. + """ + + type: Literal["default", "custom", "no_normalization"] = "default" + lower_percentile: Optional[float] = Field(None, ge=0, le=100) + upper_percentile: Optional[float] = Field(None, ge=0, le=100) + lower_bound: Optional[int] = None + upper_bound: Optional[int] = None + + # In the future, add an option to allow using precomputed percentiles + # that are stored in OME-Zarr histograms and use this pydantic model that + # those histograms actually exist + + @root_validator + def validate_conditions(cls, values): + # Extract values + type = values.get("type") + lower_percentile = values.get("lower_percentile") + upper_percentile = values.get("upper_percentile") + lower_bound = values.get("lower_bound") + upper_bound = values.get("upper_bound") + + # Verify that custom parameters are only provided when type="custom" + if type != "custom": + if lower_percentile is not None: + raise ValueError( + f"Type='{type}' but {lower_percentile=}. " + "Hint: set type='custom'." + ) + if upper_percentile is not None: + raise ValueError( + f"Type='{type}' but {upper_percentile=}. " + "Hint: set type='custom'." + ) + if lower_bound is not None: + raise ValueError( + f"Type='{type}' but {lower_bound=}. " + "Hint: set type='custom'." + ) + if upper_bound is not None: + raise ValueError( + f"Type='{type}' but {upper_bound=}. " + "Hint: set type='custom'." + ) + + # The only valid options are: + # 1. Both percentiles are set and both bounds are unset + # 2. Both bounds are set and both percentiles are unset + are_percentiles_set = ( + lower_percentile is not None, + upper_percentile is not None, + ) + are_bounds_set = ( + lower_bound is not None, + upper_bound is not None, + ) + if len(set(are_percentiles_set)) != 1: + raise ValueError( + "Both lower_percentile and upper_percentile must be set " + "together." + ) + if len(set(are_bounds_set)) != 1: + raise ValueError( + "Both lower_bound and upper_bound must be set together" + ) + if lower_percentile is not None and lower_bound is not None: + raise ValueError( + "You cannot set both explicit bounds and percentile bounds " + "at the same time. Hint: use only one of the two options." + ) + + return values + + @property + def cellpose_normalize(self) -> bool: + """ + Determine whether cellpose should apply its internal normalization. + + If type is set to `custom` or `no_normalization`, don't apply cellpose + internal normalization + """ + return self.type == "default" + + +def normalized_img( + img: np.ndarray, + axis: int = -1, + invert: bool = False, + lower_p: float = 1.0, + upper_p: float = 99.0, + lower_bound: Optional[int] = None, + upper_bound: Optional[int] = None, +): + """normalize each channel of the image so that so that 0.0=lower percentile + or lower bound and 1.0=upper percentile or upper bound of image intensities. + + The normalization can result in values < 0 or > 1 (no clipping). + + Based on https://github.com/MouseLand/cellpose/blob/4f5661983c3787efa443bbbd3f60256f4fd8bf53/cellpose/transforms.py#L375 # noqa E501 + + optional inversion + + Parameters + ------------ + + img: ND-array (at least 3 dimensions) + + axis: channel axis to loop over for normalization + + invert: invert image (useful if cells are dark instead of bright) + + lower_p: Lower percentile for rescaling + + upper_p: Upper percentile for rescaling + + lower_bound: Lower fixed-value used for rescaling + + upper_bound: Upper fixed-value used for rescaling + + Returns + --------------- + + img: ND-array, float32 + normalized image of same size + + """ + if img.ndim < 3: + error_message = "Image needs to have at least 3 dimensions" + logger.critical(error_message) + raise ValueError(error_message) + + img = img.astype(np.float32) + img = np.moveaxis(img, axis, 0) + for k in range(img.shape[0]): + if lower_p is not None: + # ptp can still give nan's with weird images + i99 = np.percentile(img[k], upper_p) + i1 = np.percentile(img[k], lower_p) + if i99 - i1 > +1e-3: # np.ptp(img[k]) > 1e-3: + img[k] = normalize_percentile( + img[k], lower=lower_p, upper=upper_p + ) + if invert: + img[k] = -1 * img[k] + 1 + else: + img[k] = 0 + elif lower_bound is not None: + if upper_bound - lower_bound > +1e-3: + img[k] = normalize_bounds( + img[k], lower=lower_bound, upper=upper_bound + ) + if invert: + img[k] = -1 * img[k] + 1 + else: + img[k] = 0 + else: + raise ValueError("No normalization method specified") + img = np.moveaxis(img, 0, axis) + return img + + +def normalize_percentile(Y: np.ndarray, lower: float = 1, upper: float = 99): + """normalize image so 0.0 is lower percentile and 1.0 is upper percentile + Percentiles are passed as floats (must be between 0 and 100) + + Args: + Y: The image to be normalized + lower: Lower percentile + upper: Upper percentile + + """ + X = Y.copy() + x01 = np.percentile(X, lower) + x99 = np.percentile(X, upper) + X = (X - x01) / (x99 - x01) + return X + + +def normalize_bounds(Y: np.ndarray, lower: int = 0, upper: int = 65535): + """normalize image so 0.0 is lower value and 1.0 is upper value + + Args: + Y: The image to be normalized + lower: Lower normalization value + upper: Upper normalization value + + """ + X = Y.copy() + X = (X - lower) / (upper - lower) + return X diff --git a/tests/tasks/test_unit_cellpose_transforms.py b/tests/tasks/test_unit_cellpose_transforms.py new file mode 100644 index 000000000..2f03d7430 --- /dev/null +++ b/tests/tasks/test_unit_cellpose_transforms.py @@ -0,0 +1,133 @@ +import numpy as np +import pytest + +from fractal_tasks_core.tasks.cellpose_transforms import ( + CellposeCustomNormalizer, +) +from fractal_tasks_core.tasks.cellpose_transforms import ( + normalized_img, +) + + +@pytest.mark.parametrize( + "type, lower_percentile, upper_percentile, lower_bound, " + "upper_bound, expected_value_error", + [ + ("default", None, None, None, None, False), + ("default", 1, 99, None, None, True), + ("default", 1, None, None, None, True), + ("default", None, 99, None, None, True), + ("default", 1, 99, 0, 100, True), + ("default", None, None, 0, 100, True), + ("default", None, None, None, 100, True), + ("default", None, None, 0, None, True), + ("no_normalization", None, None, None, None, False), + ("custom", 1, 99, None, None, False), + ("custom", 1, None, None, None, True), + ("custom", None, 99, None, None, True), + ("custom", 1, 99, 0, 100, True), + ("custom", None, None, 0, 100, False), + ("custom", None, None, None, 100, True), + ("custom", None, None, 0, None, True), + ("wrong_type", None, None, None, None, True), + ], +) +def test_CellposeCustomNormalizer( + type, + lower_percentile, + upper_percentile, + lower_bound, + upper_bound, + expected_value_error, +): + if expected_value_error: + pass + with pytest.raises(ValueError): + CellposeCustomNormalizer( + type=type, + lower_percentile=lower_percentile, + upper_percentile=upper_percentile, + lower_bound=lower_bound, + upper_bound=upper_bound, + ) + else: + if type == "default": + assert CellposeCustomNormalizer( + type=type, + lower_percentile=lower_percentile, + upper_percentile=upper_percentile, + lower_bound=lower_bound, + upper_bound=upper_bound, + ).cellpose_normalize + else: + assert not ( + CellposeCustomNormalizer( + type=type, + lower_percentile=lower_percentile, + upper_percentile=upper_percentile, + lower_bound=lower_bound, + upper_bound=upper_bound, + ).cellpose_normalize + ) + + +def test_normalized_img_percentile(): + # Create a 4D numpy array with values evenly distributed from 0 to 1000 + img = np.linspace(1, 1000, num=1000).reshape((1, 10, 10, 10)) + + # Normalize the image + normalized = normalized_img(img, axis=0, lower_p=1.0, upper_p=99.0) + + # Check dimensions are unchanged + assert ( + img.shape == normalized.shape + ), "Normalized image should have the same shape as input" + + # Check type is float32 as per the function's specification + assert ( + normalized.dtype == np.float32 + ), "Normalized image should be of type float32" + + # Check that the normalization results in the expected clipping + assert np.sum(np.sum(np.sum(np.sum(normalized <= 0)))) == 10 + assert np.sum(np.sum(np.sum(np.sum(normalized >= 1)))) == 10 + + +@pytest.mark.parametrize( + "lower_bound, upper_bound, lower_than_0, higher_than_1", + [ + (0, 901, 0, 100), + (100, 901, 100, 100), + (10, 991, 10, 10), + (1, 999, 1, 2), + ], +) +def test_normalized_img_bounds( + lower_bound, upper_bound, lower_than_0, higher_than_1 +): + # Create a 4D numpy array with values evenly distributed from 0 to 1000 + img = np.linspace(1, 1000, num=1000).reshape((1, 10, 10, 10)) + + # Normalize the image + normalized = normalized_img( + img, + axis=0, + lower_p=None, + upper_p=None, + lower_bound=lower_bound, + upper_bound=upper_bound, + ) + + # Check dimensions are unchanged + assert ( + img.shape == normalized.shape + ), "Normalized image should have the same shape as input" + + # Check type is float32 as per the function's specification + assert ( + normalized.dtype == np.float32 + ), "Normalized image should be of type float32" + + # Check that the normalization results in the expected clipping + assert np.sum(np.sum(np.sum(np.sum(normalized <= 0)))) == lower_than_0 + assert np.sum(np.sum(np.sum(np.sum(normalized >= 1)))) == higher_than_1 diff --git a/tests/tasks/test_workflows_cellpose_segmentation.py b/tests/tasks/test_workflows_cellpose_segmentation.py index 10aedf45a..48daba1ab 100644 --- a/tests/tasks/test_workflows_cellpose_segmentation.py +++ b/tests/tasks/test_workflows_cellpose_segmentation.py @@ -690,7 +690,18 @@ def test_workflow_with_per_FOV_labeling_via_script( res = subprocess.run(shlex.split(command), **run_options) # type: ignore print(res.stdout) print(res.stderr) - error_msg = f"ERROR model_type={INVALID_MODEL_TYPE} is not allowed" + # If this check fails after updating the cellpose version, you'll likely + # need to update the manifest to include a changed set of available models + # See https://github.com/fractal-analytics-platform/fractal-tasks-core/issues/401 # noqa E501 + error_msg = ( + "unexpected value; permitted: 'cyto', 'nuclei', " + "'tissuenet', 'livecell', 'cyto2', 'general', 'CP', 'CPx', " + "'TN1', 'TN2', 'TN3', 'LC1', 'LC2', 'LC3', 'LC4' " + f"(type=value_error.const; given={INVALID_MODEL_TYPE}; " + "permitted=('cyto', 'nuclei', 'tissuenet', 'livecell', " + "'cyto2', 'general', 'CP', 'CPx', 'TN1', 'TN2', 'TN3', " + "'LC1', 'LC2', 'LC3', 'LC4'))" + ) assert error_msg in res.stderr assert "urllib.error.HTTPError" not in res.stdout assert "urllib.error.HTTPError" not in res.stderr