From 81ff0a88f13e2d3f717d8dea6a7e2c8c24f3188b Mon Sep 17 00:00:00 2001 From: Mike Lippincott <1michaell2017@gmail.com> Date: Mon, 20 Jul 2026 09:54:41 -0600 Subject: [PATCH] address issues --- README.md | 2 +- pyproject.toml | 5 + src/zedprofiler/IO/feature_writing_utils.py | 49 ++--- src/zedprofiler/IO/loading_classes.py | 73 +++---- src/zedprofiler/contracts.py | 97 +++++----- .../featurization/colocalization.py | 136 +++++++------ src/zedprofiler/featurization/granularity.py | 109 ++++++----- src/zedprofiler/featurization/intensity.py | 33 ++-- src/zedprofiler/featurization/neighbors.py | 180 +++++++++--------- src/zedprofiler/featurization/texture.py | 64 +++---- .../featurization/volumesizeshape.py | 26 ++- src/zedprofiler/image_utils/image_utils.py | 70 ++++--- tests/IO/feature_writing_utils_test.py | 14 +- tests/IO/test_loading_classes.py | 10 +- tests/conftest.py | 18 +- tests/featurization/conftest.py | 7 +- tests/featurization/test_colocalization.py | 10 +- tests/featurization/test_granularity.py | 11 +- tests/featurization/test_intensity.py | 6 +- tests/featurization/test_neighbors.py | 39 ++-- .../test_neighbors_additional.py | 42 ++-- tests/featurization/test_texture.py | 11 +- tests/featurization/test_volumesizeshape.py | 6 +- tests/test_contracts.py | 2 +- tests/test_image_utils.py | 6 +- tests/test_integrations.py | 9 +- tests/test_profile_fixtures.py | 3 +- tests/test_robustness.py | 9 +- uv.lock | 14 +- 29 files changed, 602 insertions(+), 459 deletions(-) diff --git a/README.md b/README.md index 47a5ff8..00f074d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ZEDprofiler [![Documentation](https://img.shields.io/badge/documentation-available-brightgreen)](https://zedprofiler.readthedocs.io/en/latest/) ![License](https://img.shields.io/badge/license-BSD%203--Clause-blue)[![Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](#quality-gates) +# ZEDprofiler [![Documentation](https://img.shields.io/badge/documentation-available-brightgreen)](https://zedprofiler.readthedocs.io/en/latest/) ![License](https://img.shields.io/badge/license-BSD%203--Clause-blue)[![Coverage](https://img.shields.io/badge/coverage-91%25-brightgreen)](#quality-gates) [![ZEDprofiler](https://github.com/WayScience/ZEDprofiler/raw/main/logo/with-text-for-dark-bg.png)](https://github.com/WayScience/ZEDprofiler) diff --git a/pyproject.toml b/pyproject.toml index f77c9f1..fdfbf27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "pyarrow>=23.0.1", "pydantic>=2.10", "scikit-image>=0.26", + "scipy>=1.14", "tomli>=2.4.1", ] urls.Homepage = "https://zedprofiler.readthedocs.io" @@ -111,6 +112,10 @@ ignore-words-list = "inout" paths = [ "src/zedprofiler", "tests" ] min_confidence = 90 +[tool.mypy] +ignore_missing_imports = true +python_version = "3.11" + [tool.pytest] ini_options.pythonpath = [ "." ] diff --git a/src/zedprofiler/IO/feature_writing_utils.py b/src/zedprofiler/IO/feature_writing_utils.py index a0792c5..6769da7 100644 --- a/src/zedprofiler/IO/feature_writing_utils.py +++ b/src/zedprofiler/IO/feature_writing_utils.py @@ -12,28 +12,22 @@ import pandera.pandas as pa from beartype import beartype -FEATURE_NAME_COMPONENT_COLUMNS = ( - "compartment", - "channel", - "feature_type", - "measurement", -) - def _coerce_dataframe_column_names_to_strings( dataframe: pandas.DataFrame, ) -> pandas.DataFrame: - """ - Ensure DataFrame column labels are string-typed before writing. + """Ensure DataFrame column labels are string-typed before writing. Parameters ---------- dataframe : pandas.DataFrame The DataFrame whose column names should be coerced to strings. + Returns ------- pandas.DataFrame A copy of the input DataFrame with all column names coerced to strings. + """ parsed_dataframe = dataframe.copy() parsed_dataframe.columns = [str(column) for column in parsed_dataframe.columns] @@ -42,8 +36,7 @@ def _coerce_dataframe_column_names_to_strings( @beartype def remove_underscores_from_string(string: object) -> str: - """ - Remove unwanted delimiters from a string and replace them with hyphens. + """Remove unwanted delimiters from a string and replace them with hyphens. Parameters ---------- @@ -54,6 +47,7 @@ def remove_underscores_from_string(string: object) -> str: ------- str The string with unwanted delimiters removed and replaced with hyphens. + """ if not isinstance(string, str): try: @@ -64,16 +58,7 @@ def remove_underscores_from_string(string: object) -> str: f"Received input: {string!r} of type {type(string)}" ) raise ValueError(msg) from e - string = string.translate( - str.maketrans( - { - "_": "-", - ".": "-", - " ": "-", - "/": "-", - } - ) - ) + string = string.translate(str.maketrans("_. /", "----")) return string @@ -81,25 +66,26 @@ def remove_underscores_from_string(string: object) -> str: def _coerce_feature_name_components( dataframe: pandas.DataFrame, ) -> pandas.DataFrame: - """ - Normalize feature-name components using shared delimiter cleanup. + """Normalize feature-name components using shared delimiter cleanup. Parameters ---------- dataframe : pandas.DataFrame The DataFrame containing feature name components to be normalized. Expected to have columns corresponding to FEATURE NAME COMPONENT COLUMNS. + Returns ------- pandas.DataFrame A copy of the input DataFrame with feature name components normalized by removing unwanted delimiters and replacing them with hyphens. + """ parsed_dataframe = dataframe.copy() for column in FEATURE_NAME_COMPONENT_COLUMNS: if column in parsed_dataframe.columns: parsed_dataframe[column] = parsed_dataframe[column].map( - remove_underscores_from_string + remove_underscores_from_string, ) return parsed_dataframe @@ -135,10 +121,12 @@ def _coerce_feature_name_components( @beartype def format_morphology_feature_name( - compartment: object, channel: object, feature_type: object, measurement: object + compartment: object, + channel: object, + feature_type: object, + measurement: object, ) -> str: - """ - Format a morphology feature name in a consistent way across all morphology features. + """Format a morphology feature name consistently across all morphology features. This format follows specification for the following: https://github.com/WayScience/NF1_3D_organoid_profiling_pipeline/blob/main/docs/RFC-2119-Feature-Naming-Convention.md @@ -157,8 +145,8 @@ def format_morphology_feature_name( ------- str The formatted feature name. - """ + """ component_frame = pandas.DataFrame( [ { @@ -166,8 +154,8 @@ def format_morphology_feature_name( "channel": channel, "feature_type": feature_type, "measurement": measurement, - } - ] + }, + ], ) coerced_components = FEATURE_NAME_COMPONENT_SCHEMA.validate(component_frame) parsed_row = coerced_components.iloc[0] @@ -212,6 +200,7 @@ def save_features_as_parquet( Returns ------- pathlib.Path + """ validated_df = FEATURE_OUTPUT_SCHEMA.validate(df) output_prefix = format_morphology_feature_name( diff --git a/src/zedprofiler/IO/loading_classes.py b/src/zedprofiler/IO/loading_classes.py index 2bf0026..f1fc273 100644 --- a/src/zedprofiler/IO/loading_classes.py +++ b/src/zedprofiler/IO/loading_classes.py @@ -18,8 +18,7 @@ @beartype def _image_loading(image_path: pathlib.Path) -> numpy.ndarray: - """ - Internal loader using bioio as a backend + """Internal loader using bioio as a backend Parameters ---------- @@ -30,6 +29,7 @@ def _image_loading(image_path: pathlib.Path) -> numpy.ndarray: ------- numpy.ndarray Image returned + """ image = bioio.BioImage(str(image_path)) # selects the first scene found return image.get_image_data("ZYX") @@ -46,7 +46,6 @@ class ImageSetConfig: # validate the arg types def __post_init__(self) -> None: """Initialize default values for None fields.""" - if not isinstance(self.image_set_name, (str, type(None))): raise TypeError("image_set_name must be a string or None") if not isinstance(self.label_key_name, (list, type(None))): @@ -60,7 +59,7 @@ def __post_init__(self) -> None: self.raw_image_key_name = [] -class _LazyImageSetDict(dict[str, pathlib.Path | numpy.ndarray]): +class _LazyImageSetDict(dict): # type: ignore[type-arg] """Dictionary that loads image arrays on first access.""" def __getitem__(self, key: str) -> numpy.ndarray: @@ -70,7 +69,7 @@ def __getitem__(self, key: str) -> numpy.ndarray: super().__setitem__(key, value) return value - def get( + def get( # type: ignore[override] self, key: str, default: pathlib.Path | numpy.ndarray | None = None, @@ -79,18 +78,17 @@ def get( return self[key] return default - def items(self) -> Iterator[tuple[str, numpy.ndarray]]: + def items(self) -> Iterator[tuple[str, numpy.ndarray]]: # type: ignore[override] for key in dict.__iter__(self): yield key, self[key] - def values(self) -> Iterator[numpy.ndarray]: + def values(self) -> Iterator[numpy.ndarray]: # type: ignore[override] for key in dict.__iter__(self): yield self[key] class ImageSetLoader: - """ - ImageSet in this context refers to a set of images that can be + """ImageSet in this context refers to a set of images that can be related to each other via their metadata. For example all images coming from the same well, FOV or timepoint but different spectral channels and segmentation labels. @@ -164,6 +162,7 @@ def __init__( # noqa: PLR0913 config : ImageSetConfig | None Optional configuration object with image_set_name, label_key_name, and raw_image_key_name. If None, defaults are used. + """ config = config or ImageSetConfig() self._validate_input_sources( @@ -201,8 +200,7 @@ def _validate_input_sources( image_set_array: numpy.ndarray | None, label_set_array: numpy.ndarray | None, ) -> None: - """ - Validate the input sources such that either the image path or the + """Validate the input sources such that either the image path or the array is passed through but not neither and not both. Parameters @@ -224,24 +222,25 @@ def _validate_input_sources( ValueError If both image_set_array and image_set_path are provided, or if both label_set_array and label_set_path are provided. + """ if image_set_array is None and image_set_path is None: raise ValueError( - "Either image_set_array or image_set_path must be provided." + "Either image_set_array or image_set_path must be provided.", ) if label_set_array is None and label_set_path is None: raise ValueError( - "Either label_set_array or label_set_path must be provided." + "Either label_set_array or label_set_path must be provided.", ) if image_set_array is not None and image_set_path is not None: raise ValueError( "Only one of image_set_array or image_set_path should be " - "provided, not both." + "provided, not both.", ) if label_set_array is not None and label_set_path is not None: raise ValueError( "Only one of label_set_array or label_set_path should be " - "provided, not both." + "provided, not both.", ) def _load_path_based_images( @@ -251,8 +250,7 @@ def _load_path_based_images( image_set_path: pathlib.Path | None, label_set_path: pathlib.Path | None, ) -> None: - """ - Load the images if a path is given. + """Load the images if a path is given. Note that currently we only load tiffs... Parameters @@ -265,6 +263,7 @@ def _load_path_based_images( Path to the image set directory. label_set_path : pathlib.Path | None Path to the label set directory. + """ if image_set_path is None: return @@ -300,8 +299,7 @@ def _load_array_based_images( image_set_array: numpy.ndarray | None, label_set_array: numpy.ndarray | None, ) -> None: - """ - Load the array based images. + """Load the array based images. These are already in memory and stored as numpy arrays. Parameters @@ -312,33 +310,34 @@ def _load_array_based_images( Array containing the image data. label_set_array : numpy.ndarray | None Array containing the label data. + """ if image_set_array is not None: - for key in config.raw_image_key_name: + for key in config.raw_image_key_name or []: # Run through pydantic validation to ensure the array is valid. validated_array = ImageArrayModel(array=image_set_array).array self.image_set_dict[key] = validated_array if label_set_array is not None: - for key in config.label_key_name: + for key in config.label_key_name or []: # Run through pydantic validation to ensure the array is valid. validated_array = ImageArrayModel(array=label_set_array).array self.image_set_dict[key] = validated_array def get_unique_objects_in_compartments(self) -> None: - """ - Populate unique object IDs per compartment. + """Populate unique object IDs per compartment. Parameters ---------- None This method does not take any parameters. + """ self.unique_compartment_objects = {} if len(self.compartments) == 0: - self.compartments = None + return for compartment in self.compartments: self.unique_compartment_objects[compartment] = numpy.unique( - self.get_image(compartment) + self.get_image(compartment), ) # remove the 0 label self.unique_compartment_objects[compartment] = [ @@ -357,6 +356,7 @@ def get_image(self, key: str) -> numpy.ndarray: ------- numpy.ndarray Image array for the requested key. + """ return self.image_set_dict[key] @@ -367,6 +367,7 @@ def get_image_names(self) -> list[str]: ------- list[str] List of image names excluding compartment labels. + """ compartments = ( self.compartments @@ -383,6 +384,7 @@ def get_compartments(self) -> list[str]: ------- list[str] List of compartment keys. + """ self.compartments = [ x @@ -400,13 +402,13 @@ def get_anisotropy(self) -> float: ------- float Ratio of z-spacing to y-spacing. + """ return self.anisotropy_spacing[0] / self.anisotropy_spacing[1] class ObjectLoader: - """ - A class to load objects from a labeled image and extract their properties. + """A class to load objects from a labeled image and extract their properties. Where an object is defined as a segmented region in the image. This could be a cell, a nucleus, or any other compartment segmented. @@ -433,6 +435,7 @@ class ObjectLoader: __init__(image, label_image, channel_name, compartment_name) Initializes the ObjectLoader with the image, label image, channel name, and compartment name. + """ def __init__( @@ -451,8 +454,8 @@ def __init__( The name of the channel from which the objects are extracted. compartment_name : str The name of the compartment from which the objects are extracted. - """ + """ self.channel = channel_name self.compartment = compartment_name self.image = image_set_loader.get_image(self.channel) if self.channel else None @@ -460,16 +463,18 @@ def __init__( image_set_loader.get_image(self.compartment) if self.compartment else None ) # get the labeled image objects - self.object_ids = numpy.unique(self.label_image) - # drop the 0 label - self.object_ids = [x for x in self.object_ids if x != 0] + if self.label_image is not None: + self.object_ids: list[int] = [ + int(x) for x in numpy.unique(self.label_image) if x != 0 + ] + else: + self.object_ids = [] # inherit the image set loader self.image_set_loader = image_set_loader class TwoObjectLoader: - """ - A class to load two images and a label image for a specific compartment. + """A class to load two images and a label image for a specific compartment. This class is primarily used for loading images for two-channel analysis like co-localization. @@ -505,6 +510,7 @@ class TwoObjectLoader: __init__(image_set_loader, compartment, channel1, channel2) Initializes the TwoObjectLoader with the image set loader, compartment, and channel names. + """ def __init__( @@ -526,6 +532,7 @@ def __init__( First channel name to load. channel2 : str Second channel name to load. + """ self.image_set_loader = image_set_loader self.compartment = compartment diff --git a/src/zedprofiler/contracts.py b/src/zedprofiler/contracts.py index eb3109d..5d05eec 100644 --- a/src/zedprofiler/contracts.py +++ b/src/zedprofiler/contracts.py @@ -86,22 +86,22 @@ def validate_array_dtype_and_shape(_cls, arr: np.ndarray) -> np.ndarray: if len(arr_shape) == TWO_DIMENSIONAL: raise ValueError( f"Input array has shape {arr_shape} with {TWO_DIMENSIONAL} " - f"dimensions. Expected {EXPECTED_SPATIAL_DIMS} dimensions." + f"dimensions. Expected {EXPECTED_SPATIAL_DIMS} dimensions.", ) - elif len(arr_shape) == FOUR_DIMENSIONAL and arr_shape[0] > 1: + if len(arr_shape) == FOUR_DIMENSIONAL and arr_shape[0] > 1: raise ValueError( f"Input array has shape {arr_shape} with {FOUR_DIMENSIONAL} " "dimensions, but the first dimension (channels) has size " - f"{arr_shape[0]}. Expected a single-channel 3D array." + f"{arr_shape[0]}. Expected a single-channel 3D array.", ) - elif ( + if ( len(arr_shape) >= FIVE_OR_MORE_DIMENSIONS and arr_shape[0] > 1 and arr_shape[1] > 1 ): raise ValueError( f"Input array has shape {arr_shape} with {len(arr_shape)} " - f"dimensions. Expected {EXPECTED_SPATIAL_DIMS} dimensions." + f"dimensions. Expected {EXPECTED_SPATIAL_DIMS} dimensions.", ) # Check all dimensions are positive @@ -109,23 +109,22 @@ def validate_array_dtype_and_shape(_cls, arr: np.ndarray) -> np.ndarray: if dim_size <= 0: raise ValueError( f"Input array has shape {arr_shape} with non-positive " - "dimension size. All dimensions must have size greater than 0." + "dimension size. All dimensions must have size greater than 0.", ) - # Check not all dimensions are 1 + # Check that the array is not a degenerate single-voxel volume if sum(arr_shape) == len(arr_shape): raise ValueError( - f"Input array has shape {arr_shape} with one or more " - "dimensions of size 1. Expected all three dimensions to " - "have size greater than 1." + f"Input array has shape {arr_shape} with all dimensions equal to 1. " + "Expected all three dimensions to have size greater than 1.", ) if not validate_image_array_shape_contracts(arr): raise ValueError( - f"Input array with shape {arr_shape} failed shape contract validation." + f"Input array with shape {arr_shape} failed shape contract validation.", ) if not validate_image_array_type_contracts(arr): raise ValueError( - f"Input array with dtype {arr.dtype} failed type contract validation." + f"Input array with dtype {arr.dtype} failed type contract validation.", ) return arr @@ -181,23 +180,23 @@ def validate_keys_and_types(_cls, result: dict[str, Any]) -> dict[str, Any]: if actual_keys != REQUIRED_RETURN_KEYS: raise ValueError( "Return result keys must match required deterministic order " - f"{REQUIRED_RETURN_KEYS}, got {actual_keys}." + f"{REQUIRED_RETURN_KEYS}, got {actual_keys}.", ) if not isinstance(result["image_array"], np.ndarray): raise ValueError( f"Return result key 'image_array' must be a numpy array, " - f"got {type(result['image_array'])}" + f"got {type(result['image_array'])}", ) if not isinstance(result["features"], dict): raise ValueError( f"Return result key 'features' must be a dict, " - f"got {type(result['features'])}" + f"got {type(result['features'])}", ) if not isinstance(result["metadata"], dict): raise ValueError( f"Return result key 'metadata' must be a dict, " - f"got {type(result['metadata'])}" + f"got {type(result['metadata'])}", ) return result @@ -230,7 +229,7 @@ def parse_column_name(self) -> ColumnNameModel: "Metadata column name must have at least " f"{METADATA_UNDERSCORE_SEPARATED_PARTS} " "parts separated by underscores, " - f"got {len(parts)} parts in '{self.column_name}'" + f"got {len(parts)} parts in '{self.column_name}'", ) # Don't parse compartment/channel/feature for metadata columns return self @@ -240,7 +239,7 @@ def parse_column_name(self) -> ColumnNameModel: "Column name must have at least " f"{NON_METADATA_UNDERSCORE_SEPARATED_PARTS} " "parts separated by underscores, " - f"got {len(parts)} parts in '{self.column_name}'" + f"got {len(parts)} parts in '{self.column_name}'", ) self.compartment = parts[0] @@ -254,8 +253,7 @@ def parse_column_name(self) -> ColumnNameModel: def validate_image_array_shape_contracts( arr: np.ndarray, ) -> bool: - """ - Validate the input array for dimensionality + """Validate the input array for dimensionality Parameters ---------- @@ -271,40 +269,40 @@ def validate_image_array_shape_contracts( ------ ContractError If the input array does not meet the expected contract - """ + """ arr_shape = arr.shape if len(arr_shape) == TWO_DIMENSIONAL: raise ContractError( f"Input array has shape {arr_shape} with {TWO_DIMENSIONAL} dimensions. " - f"Expected {EXPECTED_SPATIAL_DIMS} dimensions." + f"Expected {EXPECTED_SPATIAL_DIMS} dimensions.", ) - elif len(arr_shape) == FOUR_DIMENSIONAL and arr_shape[0] > 1: + if len(arr_shape) == FOUR_DIMENSIONAL and arr_shape[0] > 1: raise ContractError( f"Input array has shape {arr_shape} with {FOUR_DIMENSIONAL} dimensions, " "but the first dimension (channels) has size " - f"{arr_shape[0]}. Expected a single-channel 3D array." + f"{arr_shape[0]}. Expected a single-channel 3D array.", ) - elif ( + if ( len(arr_shape) >= FIVE_OR_MORE_DIMENSIONS and arr_shape[0] > 1 and arr_shape[1] > 1 ): raise ContractError( f"Input array has shape {arr_shape} with {len(arr_shape)} dimensions. " - f"Expected {EXPECTED_SPATIAL_DIMS} dimensions." + f"Expected {EXPECTED_SPATIAL_DIMS} dimensions.", ) for dim_size in arr_shape: if dim_size <= 0: raise ContractError( f"Input array has shape {arr_shape} with non-positive dimension size. " - "All dimensions must have size greater than 0." + "All dimensions must have size greater than 0.", ) if sum(arr_shape) == len(arr_shape): raise ContractError( - f"Input array has shape {arr_shape} with one or more dimensions of size 1. " - "Expected all three dimensions to have size greater than 1." + f"Input array has shape {arr_shape} with all dimensions equal to 1. " + "Expected all three dimensions to have size greater than 1.", ) return True @@ -313,8 +311,7 @@ def validate_image_array_shape_contracts( def validate_image_array_type_contracts( arr: np.ndarray, ) -> bool: - """ - Validate the input array for type + """Validate the input array for type Parameters ---------- @@ -330,6 +327,7 @@ def validate_image_array_type_contracts( ------ ContractError If the input array does not meet the expected contract + """ if not isinstance(arr, np.ndarray): raise ContractError(f"Input is of type {type(arr)}, expected a numpy array.") @@ -337,7 +335,7 @@ def validate_image_array_type_contracts( if not np.issubdtype(arr.dtype, np.number): raise ContractError( f"Input array has dtype {arr.dtype}, expected a numeric dtype " - "(int or float)." + "(int or float).", ) return True @@ -346,8 +344,7 @@ def validate_image_array_type_contracts( def validate_return_with_pydantic( result: dict[str, object], ) -> ReturnSchemaModel: - """ - Validate return schema using Pydantic model. + """Validate return schema using Pydantic model. Parameters ---------- @@ -363,6 +360,7 @@ def validate_return_with_pydantic( ------ ContractError If validation fails + """ try: return ReturnSchemaModel(result=result) @@ -375,8 +373,7 @@ def validate_return_with_pydantic( def validate_image_with_pydantic(arr: np.ndarray) -> ImageArrayModel: - """ - Validate the input image array using Pydantic model. + """Validate the input image array using Pydantic model. Parameters ---------- @@ -392,6 +389,7 @@ def validate_image_with_pydantic(arr: np.ndarray) -> ImageArrayModel: ------ ContractError If validation fails + """ try: return ImageArrayModel(array=arr) @@ -405,8 +403,7 @@ def validate_image_with_pydantic(arr: np.ndarray) -> ImageArrayModel: @beartype def validate_column_name_with_pydantic(column_name: str) -> ColumnNameModel: - """ - Validate column name using Pydantic model. + """Validate column name using Pydantic model. Parameters ---------- @@ -422,6 +419,7 @@ def validate_column_name_with_pydantic(column_name: str) -> ColumnNameModel: ------ ContractError If validation fails + """ try: return ColumnNameModel(column_name=column_name) @@ -430,13 +428,13 @@ def validate_column_name_with_pydantic(column_name: str) -> ColumnNameModel: def create_image_array_schema() -> pa.SeriesSchema: - """ - Create a Pandera schema for image array validation. + """Create a Pandera schema for image array validation. Returns ------- pa.SeriesSchema Pandera schema for numeric arrays + """ # Use a single numeric dtype for the series schema; Pandera dtype # objects should be instantiated rather than combined with `|`. @@ -459,7 +457,7 @@ def validate_return_schema_contract( if actual_keys != REQUIRED_RETURN_KEYS: raise ContractError( "Return result keys must match required deterministic order " - f"{REQUIRED_RETURN_KEYS}, got {actual_keys}." + f"{REQUIRED_RETURN_KEYS}, got {actual_keys}.", ) try: @@ -478,7 +476,6 @@ class ExpectedFeatureNameValues(BaseModel): features: list[str] | None = Field(default_factory=list) expected_values_dict: dict[str, list[str]] = Field(default_factory=dict) model_config = ConfigDict(arbitrary_types_allowed=True) - print(features) def __init__(self, **data: object) -> None: super().__init__(**data) @@ -510,8 +507,7 @@ def validate_column_name_schema( compartments: list[str], features: list[str] | None = None, ) -> bool: - """ - Validate the column name schema for required fields and types + """Validate the column name schema for required fields and types Parameters ---------- @@ -523,20 +519,25 @@ def validate_column_name_schema( List of valid compartments for feature naming features : list[str] | None, optional List of valid features for feature naming, by default None + Returns ------- bool The status of the validation + Raises ------ ContractError If the column name does not meet the expected schema + """ non_metadata_underscore_separated_parts = NON_METADATA_UNDERSCORE_SEPARATED_PARTS metadata_underscore_separated_parts = METADATA_UNDERSCORE_SEPARATED_PARTS expected_values = ExpectedFeatureNameValues( - channels=channels, compartments=compartments, features=None + channels=channels, + compartments=compartments, + features=None, ).expected_values_dict # check if the column name is a string @@ -563,7 +564,7 @@ def validate_column_name_schema( "Metadata column name must have at least " f"{metadata_underscore_separated_parts} " "parts separated by " - f"underscores, got {len(parts)} parts in '{column_name}'" + f"underscores, got {len(parts)} parts in '{column_name}'", ) return True feature_components = pd.DataFrame( @@ -572,8 +573,8 @@ def validate_column_name_schema( "compartment": parts[0], "channel": parts[1], "feature": parts[2], - } - ] + }, + ], ) feature_component_schema = pa.DataFrameSchema( diff --git a/src/zedprofiler/featurization/colocalization.py b/src/zedprofiler/featurization/colocalization.py index 8d421bf..21c1814 100644 --- a/src/zedprofiler/featurization/colocalization.py +++ b/src/zedprofiler/featurization/colocalization.py @@ -8,11 +8,12 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Dict, Protocol, Tuple +from typing import Protocol import numpy import pandas import scipy.ndimage +import scipy.stats import skimage from zedprofiler.contracts import validate_column_name_schema @@ -32,10 +33,16 @@ UINT16_MAX = 65535 +class _SupportsImageSetName(Protocol): + """Minimal image-set-loader interface for name access.""" + + image_set_name: str | None + + class SupportsTwoObjectLoader(Protocol): """Minimal loader interface required for paired-object colocalization.""" - image_set_loader: object + image_set_loader: _SupportsImageSetName compartment: str image1: numpy.ndarray image2: numpy.ndarray @@ -47,7 +54,7 @@ def _require_scipy() -> None: if scipy is None: raise ModuleNotFoundError( "scipy is required for colocalization features. " - "Install zedprofiler with scipy." + "Install zedprofiler with scipy.", ) @@ -55,18 +62,17 @@ def _require_skimage() -> None: if skimage is None: raise ModuleNotFoundError( "scikit-image is required for colocalization features. " - "Install zedprofiler with scikit-image." + "Install zedprofiler with scikit-image.", ) -def linear_costes_threshold_calculation( +def linear_costes_threshold_calculation( # noqa: C901 first_image: numpy.ndarray, second_image: numpy.ndarray, scale_max: int = 255, fast_costes: str = "Accurate", -) -> Tuple[float, float]: - """ - Finds the Costes Automatic Threshold for colocalization using a linear algorithm. +) -> tuple[float, float]: + """Finds the Costes Automatic Threshold for colocalization using a linear algorithm. Candidate thresholds are gradually decreased until Pearson R falls below 0. If "Fast" mode is enabled the "steps" between tested thresholds will be increased when Pearson R is much greater than 0. The other mode is "Accurate" which @@ -87,10 +93,13 @@ def linear_costes_threshold_calculation( ------- Tuple[float, float] The calculated thresholds for the first and second images. + """ _require_scipy() i_step = 1 / scale_max # Step size for the threshold as a float non_zero = (first_image > 0) | (second_image > 0) + if non_zero.sum() < MIN_PEARSON_POINTS: + return 0.0, 0.0 xvar = numpy.var(first_image[non_zero], axis=0, ddof=1) yvar = numpy.var(second_image[non_zero], axis=0, ddof=1) @@ -103,8 +112,10 @@ def linear_costes_threshold_calculation( covar = 0.5 * (zvar - (xvar + yvar)) denom = 2 * covar + if denom == 0: + return 0.0, 0.0 num = (yvar - xvar) + numpy.sqrt( - (yvar - xvar) * (yvar - xvar) + 4 * (covar * covar) + (yvar - xvar) * (yvar - xvar) + 4 * (covar * covar), ) a = num / denom b = ymean - a * xmean @@ -117,8 +128,8 @@ def linear_costes_threshold_calculation( first_image_max = first_image.max() second_image_max = second_image.max() - # Initialize without a threshold - costReg, _ = scipy.stats.pearsonr(first_image, second_image) + # Initialize without a threshold (flatten so pearsonr receives 1-D arrays) + costReg, _ = scipy.stats.pearsonr(first_image.flatten(), second_image.flatten()) thr_first_image_c = i thr_second_image_c = (a * i) + b while i > first_image_max and (a * i) + b > second_image_max: @@ -131,13 +142,14 @@ def linear_costes_threshold_calculation( # Only run pearsonr if the input has changed. if (positives := numpy.count_nonzero(combt)) != num_true: costReg, _ = scipy.stats.pearsonr( - first_image[combt], second_image[combt] + first_image[combt], + second_image[combt], ) num_true = positives if costReg <= 0: break - elif fast_costes == "Accurate" or i < i_step * 10: + if fast_costes == "Accurate" or i < i_step * 10: i -= i_step elif costReg > COSTES_R_FAR_THRESHOLD: # We're way off, step down 10x @@ -156,10 +168,11 @@ def linear_costes_threshold_calculation( def bisection_costes_threshold_calculation( - first_image: numpy.ndarray, second_image: numpy.ndarray, scale_max: int = 255 + first_image: numpy.ndarray, + second_image: numpy.ndarray, + scale_max: int = 255, ) -> tuple[float, float]: - """ - Finds the Costes Automatic Threshold for colocalization using a bisection algorithm. + """Find the Costes Automatic Threshold for colocalization via bisection. Candidate thresholds are selected from within a window of possible intensities, this window is narrowed based on the R value of each tested candidate. We're looking for the first point at 0, and R value can become highly variable @@ -180,10 +193,13 @@ def bisection_costes_threshold_calculation( ------- Tuple[float, float] The calculated thresholds for the first and second images. + """ _require_scipy() non_zero = (first_image > 0) | (second_image > 0) + if non_zero.sum() < MIN_PEARSON_POINTS: + return 0.0, 0.0 xvar = numpy.var(first_image[non_zero], axis=0, ddof=1) yvar = numpy.var(second_image[non_zero], axis=0, ddof=1) @@ -196,6 +212,8 @@ def bisection_costes_threshold_calculation( covar = 0.5 * (zvar - (xvar + yvar)) denom = 2 * covar + if denom == 0: + return 0.0, 0.0 num = (yvar - xvar) + numpy.sqrt((yvar - xvar) * (yvar - xvar) + 4 * (covar**2)) a = num / denom b = ymean - a * xmean @@ -203,7 +221,7 @@ def bisection_costes_threshold_calculation( # Initialize variables left = 1 right = scale_max - mid = ((right - left) // (6 / 5)) + left + mid = (right - left) * 5 // 6 + left lastmid = 0 # Marks the value with the last positive R value. valid = 1 @@ -218,7 +236,8 @@ def bisection_costes_threshold_calculation( else: try: costReg, _ = scipy.stats.pearsonr( - first_image[combt], second_image[combt] + first_image[combt], + second_image[combt], ) if costReg < 0: left = mid - 1 @@ -230,7 +249,7 @@ def bisection_costes_threshold_calculation( left = mid - 1 lastmid = mid if right - left > WIDE_BISECTION_WINDOW: - mid = ((right - left) // (6 / 5)) + left + mid = (right - left) * 5 // 6 + left else: mid = ((right - left) // 2) + left @@ -247,9 +266,8 @@ def prepare_two_images_for_colocalization( # noqa: PLR0913 image_object2: numpy.ndarray, object_id1: int, object_id2: int, -) -> Tuple[numpy.ndarray, numpy.ndarray]: - """ - Prepare two images for colocalization analysis by cropping to object bbox. +) -> tuple[numpy.ndarray, numpy.ndarray]: + """Prepare two images for colocalization analysis by cropping to object bbox. It selects objects from label images, calculates their bounding boxes, and crops both images accordingly. @@ -272,6 +290,7 @@ def prepare_two_images_for_colocalization( # noqa: PLR0913 ------- Tuple[numpy.ndarray, numpy.ndarray] The two cropped images for colocalization analysis. + """ _require_skimage() label_object1 = select_objects_from_label(label_object1, object_id1) @@ -309,9 +328,8 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915 cropped_image_2: numpy.ndarray, thr: int = 15, fast_costes: str = "Accurate", -) -> Dict[str, float]: - """ - This function calculates the colocalization coefficients between two images. +) -> dict[str, float]: + """This function calculates the colocalization coefficients between two images. It computes the correlation coefficient, Manders' coefficients, overlap coefficient, and Costes' coefficients. The results are returned as a dictionary. @@ -333,6 +351,7 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915 ------- Dict[str, float] The output features for colocalization analysis. + """ _require_scipy() results = {} @@ -354,21 +373,20 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915 std2 = numpy.sqrt(numpy.sum((cropped_image_2 - mean2) ** 2)) x = cropped_image_1 - mean1 # x is not the same as the x dimension here y = cropped_image_2 - mean2 # y is not the same as the y dimension here - corr = numpy.sum(x * y) / (std1 * std2) + denom = std1 * std2 + corr = numpy.sum(x * y) / denom if denom > 0 else 0.0 ################################################################################################ # Calculate the Manders' coefficients ################################################################################################ # Threshold as percentage of maximum intensity of objects in each channel + combined_thresh = numpy.zeros(cropped_image_1.shape, dtype=bool) + first_image_thresh = numpy.array([], dtype=cropped_image_1.dtype) + second_image_thresh = numpy.array([], dtype=cropped_image_2.dtype) try: tff = (thr / 100) * numpy.max(cropped_image_1) tss = (thr / 100) * numpy.max(cropped_image_2) - # Ensure thresholds are at least 1 to avoid zero thresholding - # if an errors occurs this is probably due to empty images - # or images where the bbox is incredibly small and inconsistent - # or the bbox is on the border of the image - # in which case we want to remove anyway except ValueError: M1, M2 = 0.0, 0.0 else: @@ -382,7 +400,7 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915 cropped_image_1[cropped_image_1 >= tff], ) tot_second_image_thr = scipy.ndimage.sum( - cropped_image_2[cropped_image_2 >= tss] + cropped_image_2[cropped_image_2 >= tss], ) if tot_first_image_thr > 0 and tot_second_image_thr > 0: @@ -401,22 +419,15 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915 cropped_image_2[combined_thresh] ** 2, ) pdt = numpy.sqrt(numpy.array(fpsq) * numpy.array(spsq)) - overlap = ( - scipy.ndimage.sum( - cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh], + if pdt > 0: + overlap = ( + scipy.ndimage.sum( + cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh], + ) + / pdt ) - / pdt - ) - # leave in for now given they are not exported but still calculated - K1 = scipy.ndimage.sum( - cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh], - ) / (numpy.array(fpsq)) - K2 = scipy.ndimage.sum( - cropped_image_1[combined_thresh] * cropped_image_2[combined_thresh], - ) / (numpy.array(spsq)) - if K1 == K2: - pass - + else: + overlap = 0.0 # first_pixels, second_pixels = flattened image arrays # combined_thresh = boolean mask of pixels above threshold in both channels # fi_thresh, si_thresh = thresholded intensities (same shape as pixels) @@ -475,12 +486,17 @@ def calculate_colocalization( # noqa: PLR0912, PLR0915 scale = UINT8_MAX if fast_costes == "Accurate": - thr_first_image_c, thr_second_image_c = bisection_costes_threshold_calculation( - cropped_image_1, cropped_image_2, scale + thr_first_image_c, thr_second_image_c = linear_costes_threshold_calculation( + cropped_image_1, + cropped_image_2, + scale, + fast_costes, ) else: - thr_first_image_c, thr_second_image_c = linear_costes_threshold_calculation( - cropped_image_1, cropped_image_2, scale, fast_costes + thr_first_image_c, thr_second_image_c = bisection_costes_threshold_calculation( + cropped_image_1, + cropped_image_2, + scale, ) # Costes' thershold for entire image is applied to each object @@ -524,9 +540,8 @@ def compute_colocalization( # noqa: C901, PLR0912 fast_costes: str = "Accurate", channel1: str | None = None, channel2: str | None = None, -) -> dict[str, list[float]]: - """ - Compute colocalization features for pairs of objects from two channels. +) -> pandas.DataFrame: + """Compute colocalization features for pairs of objects from two channels. Parameters ---------- @@ -547,9 +562,10 @@ def compute_colocalization( # noqa: C901, PLR0912 Returns ------- - dict[str, list[float]] - A dictionary containing lists of colocalization feature values for - each object pair. + pandas.DataFrame + Wide-format DataFrame with one row per object pair and one column per + colocalization metric, plus Metadata columns. + """ if channel1 is None or channel2 is None: raise ValueError("channel1 and channel2 must be provided for feature naming.") @@ -566,8 +582,8 @@ def compute_colocalization( # noqa: C901, PLR0912 colocalization_features = calculate_colocalization( cropped_image_1=cropped_image1, cropped_image_2=cropped_image2, - thr=15, - fast_costes="Accurate", + thr=thr, + fast_costes=fast_costes, ) # Build a simple dict row (avoid pandas dependency) @@ -600,7 +616,7 @@ def compute_colocalization( # noqa: C901, PLR0912 # Convert list of row-dicts into a dict-of-lists with stable ordering if not list_of_dfs: - return {} + return pandas.DataFrame() # Collect other metric keys preserving first-seen ordering other_keys: list[str] = [] diff --git a/src/zedprofiler/featurization/granularity.py b/src/zedprofiler/featurization/granularity.py index 31c9717..7ef7ffb 100644 --- a/src/zedprofiler/featurization/granularity.py +++ b/src/zedprofiler/featurization/granularity.py @@ -1,11 +1,7 @@ -""" -Calculate the granularity spectrum of a 3D image. -""" +"""Calculate the granularity spectrum of a 3D image.""" from __future__ import annotations -from typing import Dict, Optional - import numpy import pandas import scipy.ndimage @@ -32,6 +28,7 @@ def _fix_scipy_ndimage_result(result: float | list | numpy.ndarray) -> numpy.nda ------- numpy.ndarray 1-D array of results. + """ if numpy.isscalar(result): return numpy.array([result]) @@ -65,6 +62,7 @@ def _subsample_3d( ------- numpy.ndarray Subsampled array. + """ if subsample_factor >= 1.0: return data.copy() @@ -100,13 +98,19 @@ def _upsample_3d( ------- numpy.ndarray Upsampled array at original_shape resolution. + """ k, i, j = numpy.mgrid[ - 0 : original_shape[0], 0 : original_shape[1], 0 : original_shape[2] + 0 : original_shape[0], + 0 : original_shape[1], + 0 : original_shape[2], ].astype(float) - k *= float(subsampled_shape[0] - 1) / float(original_shape[0] - 1) - i *= float(subsampled_shape[1] - 1) / float(original_shape[1] - 1) - j *= float(subsampled_shape[2] - 1) / float(original_shape[2] - 1) + if original_shape[0] > 1: + k *= float(subsampled_shape[0] - 1) / float(original_shape[0] - 1) + if original_shape[1] > 1: + i *= float(subsampled_shape[1] - 1) / float(original_shape[1] - 1) + if original_shape[2] > 1: + j *= float(subsampled_shape[2] - 1) / float(original_shape[2] - 1) return scipy.ndimage.map_coordinates(data, (k, i, j), order=1) @@ -118,8 +122,8 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 image_sample_size: float = 0.25, mask_threshold: float = 0.9, verbose: bool = False, - image_mask: Optional[numpy.ndarray] = None, -) -> Dict[str, list]: + image_mask: numpy.ndarray | None = None, +) -> pandas.DataFrame: """Calculate the granularity spectrum of a 3D image. Follows the CellProfiler MeasureGranularity algorithm exactly for 3D: @@ -160,25 +164,29 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 Returns ------- - Dict[str, list] - Dictionary with keys 'object_id', 'feature', 'value'. - Image-level measurements use object_id=0. + pandas.DataFrame + Wide-format DataFrame with one row per object and one column per + granularity scale, plus Metadata columns. + """ # Validate inputs if subsample_size <= 0 or subsample_size > 1: raise ValueError(f"subsample_size must be in (0, 1], got {subsample_size}") if image_sample_size <= 0 or image_sample_size > 1: raise ValueError( - f"image_sample_size must be in (0, 1], got {image_sample_size}" + f"image_sample_size must be in (0, 1], got {image_sample_size}", ) if radius <= 0: raise ValueError(f"radius must be positive, got {radius}") if granular_spectrum_length <= 0: raise ValueError( - f"granular_spectrum_length must be positive, got {granular_spectrum_length}" + "granular_spectrum_length must be positive, " + f"got {granular_spectrum_length}", ) # Get original data + if object_loader.image is None or object_loader.label_image is None: + return pandas.DataFrame() original_pixels = object_loader.image original_labels = object_loader.label_image original_shape = original_pixels.shape @@ -218,7 +226,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 if verbose: print( f"Subsampled image: {original_shape} -> {pixels.shape} " - f"(factor={subsample_size})" + f"(factor={subsample_size})", ) else: pixels = original_pixels.copy() @@ -242,7 +250,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # (NOT mgrid[0:back_shape] / image_sample_size as 2D does) k, i, j = ( numpy.mgrid[0 : new_shape[0], 0 : new_shape[1], 0 : new_shape[2]].astype( - float + float, ) / subsample_size ) @@ -256,7 +264,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 print( f"Background subsampled: pixels {pixels.shape} -> " f"back_pixels {back_pixels.shape} " - f"(image_sample_size={image_sample_size})" + f"(image_sample_size={image_sample_size})", ) else: back_pixels = pixels @@ -273,7 +281,8 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 back_pixels_masked = numpy.zeros_like(back_pixels) back_pixels_masked[back_mask] = back_pixels[back_mask] back_pixels = skimage.morphology.dilation( - back_pixels_masked, footprint=footprint_bg + back_pixels_masked, + footprint=footprint_bg, ) # Upsample background back to subsampled image size @@ -281,7 +290,9 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # CellProfiler 3D: mgrid[0:new_shape] with coords scaled by # (back_shape - 1) / (new_shape - 1) k, i, j = numpy.mgrid[ - 0 : new_shape[0], 0 : new_shape[1], 0 : new_shape[2] + 0 : new_shape[0], + 0 : new_shape[1], + 0 : new_shape[2], ].astype(float) k *= float(back_shape[0] - 1) / float(new_shape[0] - 1) i *= float(back_shape[1] - 1) / float(new_shape[1] - 1) @@ -301,7 +312,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # (im.pixel_data) using the full-resolution label image, with labels # masked by im.mask: labels[~im.mask] = 0. # ------------------------------------------------------------------ - object_measurements = { + object_measurements: dict[str, list] = { "Metadata_Object_ObjectID": [], "feature": [], "value": [], @@ -315,12 +326,13 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 masked_labels = original_labels.copy() masked_labels[~original_mask] = 0 - per_object_current_mean = _fix_scipy_ndimage_result( - scipy.ndimage.mean(original_pixels, masked_labels, label_range) - ) - per_object_start_mean = numpy.maximum( - per_object_current_mean, numpy.finfo(float).eps - ) + if numpy.any(masked_labels > 0): + per_object_current_mean = _fix_scipy_ndimage_result( + scipy.ndimage.mean(original_pixels, masked_labels, label_range), + ) + else: + per_object_current_mean = numpy.zeros(len(label_range)) + per_object_start_mean = per_object_current_mean.copy() else: label_range = numpy.array([], dtype=int) masked_labels = original_labels @@ -332,12 +344,11 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # CellProfiler computes startmean AFTER background subtraction but # BEFORE zeroing pixels outside mask (zeroing is implicit via indexing). # ------------------------------------------------------------------ - startmean = numpy.mean(pixels[mask]) + startmean = numpy.mean(pixels[mask]) if mask.any() else 0.0 ero = pixels.copy() # Mask the test image so masked pixels have no effect during reconstruction ero[~mask] = 0 currentmean = startmean - startmean = max(startmean, numpy.finfo(float).eps) # CellProfiler uses ball(1) for the iterative erosion/reconstruction loop footprint = skimage.morphology.ball(1, dtype=bool) @@ -346,7 +357,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 print( f"Image startmean: {startmean:.6f}, " f"Processing {nobjects} objects, " - f"Spectrum length: {granular_spectrum_length}" + f"Spectrum length: {granular_spectrum_length}", ) for scale in range(1, granular_spectrum_length + 1): @@ -361,8 +372,8 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 rec = skimage.morphology.reconstruction(ero, pixels, footprint=footprint) # Image-level granularity - currentmean = numpy.mean(rec[mask]) - gs = (prevmean - currentmean) * 100 / startmean + currentmean = numpy.mean(rec[mask]) if mask.any() else 0.0 + gs = (prevmean - currentmean) * 100 / startmean if startmean > 0 else 0.0 if verbose and scale == 1: print(f"Scale 1 - gs: {gs:.4f}, currentmean: {currentmean:.6f}") @@ -382,15 +393,24 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 rec_full = rec # Single-pass per-object mean via scipy.ndimage.mean - new_object_means = _fix_scipy_ndimage_result( - scipy.ndimage.mean(rec_full, masked_labels, label_range) - ) + if numpy.any(masked_labels > 0): + new_object_means = _fix_scipy_ndimage_result( + scipy.ndimage.mean(rec_full, masked_labels, label_range), + ) + else: + new_object_means = numpy.zeros(len(label_range)) # Granular spectrum: (prev - new) * 100 / start, per object - gss = ( - (per_object_current_mean - new_object_means) - * 100 - / per_object_start_mean + # Guard against zero start mean — return 0 rather than dividing by eps + _safe_denom = numpy.where( + per_object_start_mean > 0, + per_object_start_mean, + 1.0, + ) + gss = numpy.where( + per_object_start_mean > 0, + (per_object_current_mean - new_object_means) * 100 / _safe_denom, + 0.0, ) per_object_current_mean = new_object_means @@ -398,7 +418,7 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # Record measurements for each object for idx in range(len(label_range)): object_measurements["Metadata_Object_ObjectID"].append( - int(label_range[idx]) + int(label_range[idx]), ) object_measurements["feature"].append(scale) object_measurements["value"].append(float(gss[idx])) @@ -413,10 +433,10 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 print(f"Mean granularity: {numpy.mean(vals):.2f}") final_df = pandas.DataFrame(object_measurements) - # get the mean of each value in the array - # melt the dataframe to wide format final_df = final_df.pivot_table( - index=["Metadata_Object_ObjectID"], columns=["feature"], values=["value"] + index=["Metadata_Object_ObjectID"], + columns=["feature"], + values=["value"], ) final_df.columns = final_df.columns.droplevel() final_df = final_df.reset_index() @@ -441,7 +461,6 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 object_loader.image_set_loader.image_set_name, ) result = final_df.to_dict(orient="list") - for col in list(result.keys()): try: validate_column_name_schema( diff --git a/src/zedprofiler/featurization/intensity.py b/src/zedprofiler/featurization/intensity.py index a7feac9..2f947b5 100644 --- a/src/zedprofiler/featurization/intensity.py +++ b/src/zedprofiler/featurization/intensity.py @@ -16,8 +16,7 @@ def get_outline(mask: numpy.ndarray) -> numpy.ndarray: - """ - Get the outline of a 3D mask. + """Get the outline of a 3D mask. Parameters ---------- @@ -28,18 +27,18 @@ def get_outline(mask: numpy.ndarray) -> numpy.ndarray: ------- numpy.ndarray The outline of the mask. + """ outline = numpy.zeros_like(mask) for z in range(mask.shape[0]): - outline[z] = skimage.segmentation.find_boundaries(mask[z]) + outline[z] = skimage.segmentation.find_boundaries(mask[z], mode="inner") return outline def compute_intensity( # noqa: PLR0915 object_loader: ObjectLoader, ) -> pandas.DataFrame: - """ - Measure the intensity of objects in a 3D image. + """Measure the intensity of objects in a 3D image. Parameters ---------- @@ -48,15 +47,18 @@ def compute_intensity( # noqa: PLR0915 Returns ------- - dict - A dictionary containing the measurements for each object. - The keys are the measurement names and the values are the corresponding values. + pandas.DataFrame + Wide-format DataFrame with one row per object and one column per + intensity measurement, plus Metadata columns. + """ + if object_loader.label_image is None or object_loader.image is None: + return pandas.DataFrame() image_object = object_loader.image label_object = object_loader.label_image labels = object_loader.object_ids - output_dict = { + output_dict: dict[str, list] = { "Metadata_Object_ObjectID": [], "feature_name": [], "channel": [], @@ -86,10 +88,14 @@ def compute_intensity( # noqa: PLR0915 # Crop to bounding box for efficiency cropped_label = selected_label_object[ - min_z : max_z + 1, min_y : max_y + 1, min_x : max_x + 1 + min_z : max_z + 1, + min_y : max_y + 1, + min_x : max_x + 1, ] cropped_image = selected_image_object[ - min_z : max_z + 1, min_y : max_y + 1, min_x : max_x + 1 + min_z : max_z + 1, + min_y : max_y + 1, + min_x : max_x + 1, ] # Create coordinate grids for the bounding box @@ -186,10 +192,7 @@ def compute_intensity( # noqa: PLR0915 } for feature_name, measurement_value in measurements_dict.items(): - if measurement_value.dtype != numpy.float32: - coerced_value = numpy.float32(measurement_value) - else: - coerced_value = measurement_value + coerced_value = numpy.float32(measurement_value) output_dict["Metadata_Object_ObjectID"].append(numpy.int32(label)) output_dict["feature_name"].append(feature_name) output_dict["channel"].append(object_loader.channel) diff --git a/src/zedprofiler/featurization/neighbors.py b/src/zedprofiler/featurization/neighbors.py index 3c2e17a..eed2cba 100644 --- a/src/zedprofiler/featurization/neighbors.py +++ b/src/zedprofiler/featurization/neighbors.py @@ -1,30 +1,30 @@ """Neighbors featurization module.""" -from typing import Dict, Tuple, Union +import warnings import matplotlib.pyplot as plt import numpy import pandas +import scipy.ndimage import skimage.measure from zedprofiler.contracts import validate_column_name_schema from zedprofiler.IO.feature_writing_utils import format_morphology_feature_name from zedprofiler.IO.loading_classes import ObjectLoader -BBoxCoord = Union[int, float] -BBox3D = Tuple[BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord] +BBoxCoord = int +BBox3D = tuple[BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord] SMALL_SAMPLE_THRESHOLD = 20 def neighbors_expand_box( - min_coor: Union[int, float], - max_coord: Union[int, float], - current_min: Union[int, float], - current_max: Union[int, float], + min_coor: int, + max_coord: int, + current_min: int, + current_max: int, expand_by: int, -) -> Tuple[Union[int, float], Union[int, float]]: - """ - Expand the bounding box of the object by a specified distance in each direction. +) -> tuple[int, int]: + """Expand the bounding box of the object by a specified distance in each direction. Parameters ---------- @@ -43,6 +43,7 @@ def neighbors_expand_box( ------- Tuple[Union[int, float], Union[int, float]] The new minimum and maximum coordinates of the bounding box. + """ if current_min - expand_by < min_coor: current_min = min_coor @@ -60,8 +61,7 @@ def crop_3D_image( image: numpy.ndarray, bbox: BBox3D, ) -> numpy.ndarray: - """ - Crop the 3D image to the bounding box of the object. + """Crop the 3D image to the bounding box of the object. Parameters ---------- @@ -74,6 +74,7 @@ def crop_3D_image( ------- numpy.ndarray The cropped 3D image. + """ z1, y1, x1, z2, y2, x2 = bbox return image[z1:z2, y1:y2, x1:x2] @@ -83,9 +84,8 @@ def compute_neighbors( object_loader: ObjectLoader, distance_threshold: int = 10, anisotropy_factor: int = 10, -) -> Dict[str, list]: - """ - This function calculates the number of neighbors for each object in a 3D image. +) -> pandas.DataFrame: + """This function calculates the number of neighbors for each object in a 3D image. Parameters ---------- @@ -100,10 +100,13 @@ def compute_neighbors( Returns ------- - Dict[str, list] - A dictionary containing the object ID and the number of neighbors for - each object. + pandas.DataFrame + Wide-format DataFrame with one row per object and columns for + NeighborsCountAdjacent and NeighborsCountByDistance, plus Metadata columns. + """ + if object_loader.label_image is None: + return pandas.DataFrame() label_object = object_loader.label_image labels = object_loader.object_ids # set image global min and max coordinates @@ -114,16 +117,15 @@ def compute_neighbors( image_global_max_coord_y = label_object.shape[1] image_global_max_coord_x = label_object.shape[2] - neighbors_out_dict = { + neighbors_out_dict: dict[str, list] = { "Metadata_Object_ObjectID": [], "NeighborsCountAdjacent": [], f"NeighborsCountByDistance-{distance_threshold}": [], } for index, label in enumerate(labels): - selected_label_object = label_object.copy() - selected_label_object[selected_label_object != label] = 0 props_label = skimage.measure.regionprops_table( - selected_label_object, properties=["bbox"] + (label_object == label).astype(numpy.uint8), + properties=["bbox"], ) # get the number of neighbors for each object distance_x_y = distance_threshold @@ -138,8 +140,6 @@ def compute_neighbors( props_label["bbox-4"][0], props_label["bbox-5"][0], ) - original_bbox = (z_min, y_min, x_min, z_max, y_max, x_max) - new_z_min, new_z_max = neighbors_expand_box( min_coor=image_global_min_coord_z, max_coord=image_global_max_coord_z, @@ -163,19 +163,14 @@ def compute_neighbors( ) bbox = (new_z_min, new_y_min, new_x_min, new_z_max, new_y_max, new_x_max) croppped_neighbor_image = crop_3D_image(image=label_object, bbox=bbox) - self_cropped_neighbor_image = crop_3D_image( - image=label_object, bbox=original_bbox - ) - # find all the unique values in the cropped image of the object of interest - # this is the number of neighbors in the cropped image - n_neighbors_adjacent = ( - len( - numpy.unique( - self_cropped_neighbor_image[self_cropped_neighbor_image > 0] - ) - ) - - 1 - ) + + # Adjacent neighbors: dilate the object mask by 1 voxel and find labels + # that overlap the dilated region (excluding self and background). + # The tight-bbox crop misses neighbors that only touch the outer face. + object_mask = label_object == label + dilated_mask = scipy.ndimage.binary_dilation(object_mask) + touching = numpy.unique(label_object[dilated_mask & ~object_mask]) + n_neighbors_adjacent = int(numpy.sum(touching > 0)) # find all the unique values in the expanded cropped image of the # object of interest @@ -186,7 +181,7 @@ def compute_neighbors( neighbors_out_dict["Metadata_Object_ObjectID"].append(label) neighbors_out_dict["NeighborsCountAdjacent"].append(n_neighbors_adjacent) neighbors_out_dict[f"NeighborsCountByDistance-{distance_threshold}"].append( - n_neighbors_by_distance + n_neighbors_by_distance, ) final_df = pandas.DataFrame(neighbors_out_dict) # rename @@ -227,10 +222,10 @@ def compute_neighbors( def get_coordinates( - nuclei_mask: numpy.ndarray, object_ids: list | None = None + nuclei_mask: numpy.ndarray, + object_ids: list | None = None, ) -> pandas.DataFrame: - """ - Extract coordinates from a labeled mask. + """Extract coordinates from a labeled mask. Parameters ---------- @@ -243,10 +238,16 @@ def get_coordinates( ------- coords : pandas.DataFrame DataFrame with columns: object_id, x, y, z + """ if object_ids is None: object_ids = [] - coords = {"Metadata_Object_ObjectID": [], "x": [], "y": [], "z": []} + coords: dict[str, list] = { + "Metadata_Object_ObjectID": [], + "x": [], + "y": [], + "z": [], + } for obj_id in object_ids: z, y, x = numpy.where(nuclei_mask == obj_id) @@ -265,7 +266,8 @@ def calculate_centroid(coords: pandas.DataFrame) -> numpy.ndarray: def euclidean_distance_from_centroid( - coords: numpy.ndarray, centroid: numpy.ndarray + coords: numpy.ndarray, + centroid: numpy.ndarray, ) -> numpy.ndarray: """Calculate Euclidean distance from centroid for each cell.""" coords = numpy.asarray(coords, dtype=float) @@ -274,10 +276,11 @@ def euclidean_distance_from_centroid( def mahalanobis_distance_from_centroid( - coords: numpy.ndarray, centroid: numpy.ndarray, min_cells_threshold: int = 50 + coords: numpy.ndarray, + centroid: numpy.ndarray, + min_cells_threshold: int = 50, ) -> numpy.ndarray: - """ - Calculate Mahalanobis distance from centroid for each cell. + """Calculate Mahalanobis distance from centroid for each cell. This accounts for the covariance structure (shape) of the organoid. For small sample sizes (<50 cells), uses regularization or falls back to Euclidean. @@ -295,6 +298,7 @@ def mahalanobis_distance_from_centroid( ------- distances : ndarray Mahalanobis distances for each cell + """ coords = numpy.asarray(coords, dtype=float) centroid = numpy.asarray(centroid, dtype=float) @@ -303,9 +307,9 @@ def mahalanobis_distance_from_centroid( # For very small samples, use Euclidean distance instead if n_cells < SMALL_SAMPLE_THRESHOLD: - print( - f" WARNING: Only {n_cells} cells. Using Euclidean distance " - f"instead of Mahalanobis." + warnings.warn( + f"Only {n_cells} cells. Using Euclidean distance instead of Mahalanobis.", + stacklevel=2, ) return euclidean_distance_from_centroid(coords, centroid) @@ -317,9 +321,10 @@ def mahalanobis_distance_from_centroid( # Regularization strength inversely proportional to sample size reg_strength = (min_cells_threshold - n_cells) / min_cells_threshold * 0.1 cov_matrix += numpy.eye(3) * reg_strength * numpy.trace(cov_matrix) / 3 - print( - f" WARNING: Only {n_cells} cells. Using regularized covariance " - f"(λ={reg_strength:.3f})" + warnings.warn( + f"Only {n_cells} cells. Using regularized covariance " + f"(λ={reg_strength:.3f})", + stacklevel=2, ) else: # Standard small regularization for numerical stability @@ -329,8 +334,7 @@ def mahalanobis_distance_from_centroid( try: inv_cov = numpy.linalg.inv(cov_matrix) except numpy.linalg.LinAlgError: - # Fallback to pseudo-inverse if singular - print(" WARNING: Singular covariance matrix. Using pseudo-inverse.") + warnings.warn("Singular covariance matrix. Using pseudo-inverse.", stacklevel=2) inv_cov = numpy.linalg.pinv(cov_matrix) # Calculate Mahalanobis distance for each point @@ -345,10 +349,9 @@ def classify_cells_into_shells( n_shells: int = 5, method: str = "mahalanobis", min_cells_per_shell: int = 3, - centroid: numpy.ndarray = None, -) -> dict: - """ - Classify cells into radial shells based on distance from centroid. + centroid: numpy.ndarray | None = None, +) -> tuple[dict, numpy.ndarray | None]: + """Classify cells into radial shells based on distance from centroid. Automatically adjusts n_shells for small organoids to ensure meaningful statistics. @@ -373,6 +376,7 @@ def classify_cells_into_shells( - 'DistancesFromCenter': Distance from centroid for each cell - 'DistancesFromExterior': Distance from exterior for each cell - 'NormalizedDistancesFromCenter': Normalized distances (0-1) + """ # Handle both DataFrame and dict input if isinstance(coords, pandas.DataFrame): @@ -382,16 +386,15 @@ def classify_cells_into_shells( object_ids = numpy.array(coords["Metadata_Object_ObjectID"]) coords_array = numpy.column_stack([coords["x"], coords["y"], coords["z"]]) if len(coords_array) == 0: - results = { + results: dict = { "Metadata_Object_ObjectID": [], "ShellAssignments": [], "DistancesFromCenter": [], "DistancesFromExterior": [], "NormalizedDistancesFromCenter": [], - "MaxShellsUsed": [], + "ShellsUsed": [], } - centroid = None - return results, centroid + return results, None n_cells = len(coords_array) if centroid is None: centroid = calculate_centroid(coords_array) @@ -399,11 +402,11 @@ def classify_cells_into_shells( # Adjust number of shells for small organoids max_shells = max(2, n_cells // min_cells_per_shell) if n_shells > max_shells: - print( - f" WARNING: {n_cells} cells with {n_shells} shells = " - f"{n_cells / n_shells:.1f} cells/shell" + warnings.warn( + f"{n_cells} cells with {n_shells} shells = {n_cells / n_shells:.1f} " + f"cells/shell; reducing to {max_shells} shells for statistical reliability", + stacklevel=2, ) - print(f" Reducing to {max_shells} shells for statistical reliability") n_shells = max_shells # Calculate distances based on method @@ -414,7 +417,8 @@ def classify_cells_into_shells( # Normalize distances to 0-1 range max_distance = numpy.percentile( - distances, 95 + distances, + 95, ) # Use 95 percentile to avoid outliers if max_distance == 0: # All cells are at the same location; assign all to shell 0 @@ -444,8 +448,7 @@ def classify_cells_into_shells( def create_results_dataframe(results: dict) -> pandas.DataFrame: - """ - Create a pandas DataFrame with all cell information. + """Create a pandas DataFrame with all cell information. Parameters ---------- @@ -456,13 +459,14 @@ def create_results_dataframe(results: dict) -> pandas.DataFrame: ------- df : pandas.DataFrame DataFrame with cell information + """ # Handle both DataFrame and dict input if isinstance(results, dict): df = pandas.DataFrame.from_dict(results) else: raise ValueError( - "Input must be a results dictionary from classify_cells_into_shells." + "Input must be a results dictionary from classify_cells_into_shells.", ) return df @@ -472,10 +476,9 @@ def visualize_organoid_shells( coords: pandas.DataFrame, classification_results: dict, title: str = "Organoid Shell Classification", - centroid: numpy.ndarray = None, -) -> plt.figure: - """ - Create 3D visualization of organoid with shell coloring. + centroid: numpy.ndarray | None = None, +) -> plt.Figure: + """Create 3D visualization of organoid with shell coloring. Parameters ---------- @@ -485,6 +488,7 @@ def visualize_organoid_shells( Results from classify_cells_into_shells title : str Plot title + """ # Handle both DataFrame and dict input if isinstance(coords, pandas.DataFrame): @@ -503,16 +507,17 @@ def visualize_organoid_shells( shell_assignments = classification_results["ShellAssignments"] n_shells = classification_results.get( - "ShellsUsed", len(numpy.unique(shell_assignments)) + "ShellsUsed", + len(numpy.unique(shell_assignments)), ) # Red to blue color gradient - colors = plt.cm.RdYlBu_r(numpy.linspace(0, 1, n_shells)) + colors = plt.cm.RdYlBu_r(numpy.linspace(0, 1, n_shells)) # type: ignore[attr-defined] for shell in range(n_shells): mask = shell_assignments == shell if numpy.sum(mask) > 0: # Only plot if shell has cells - ax1.scatter( + ax1.scatter( # type: ignore[misc] x_coords[mask], y_coords[mask], z_coords[mask], @@ -525,7 +530,7 @@ def visualize_organoid_shells( ) if centroid is not None: - ax1.scatter( + ax1.scatter( # type: ignore[misc] *centroid, c="black", s=200, @@ -537,7 +542,7 @@ def visualize_organoid_shells( ax1.set_xlabel("X") ax1.set_ylabel("Y") - ax1.set_zlabel("Z") + ax1.set_zlabel("Z") # type: ignore[attr-defined] ax1.set_title(title) ax1.legend(loc="upper right", fontsize=8) @@ -545,7 +550,11 @@ def visualize_organoid_shells( ax2 = fig.add_subplot(122) shell_counts = [numpy.sum(shell_assignments == i) for i in range(n_shells)] bars = ax2.bar( - range(1, n_shells + 1), shell_counts, color=colors, alpha=0.7, edgecolor="black" + range(1, n_shells + 1), + shell_counts, + color=colors, + alpha=0.7, + edgecolor="black", ) ax2.set_xlabel("Shell Number") ax2.set_ylabel("Number of Cells") @@ -582,10 +591,10 @@ def visualize_organoid_shells( def plot_distance_distributions( - classification_results: dict, n_shells: int | None = None -) -> plt.figure: - """ - Plot distance distributions for each shell. + classification_results: dict, + n_shells: int | None = None, +) -> plt.Figure: + """Plot distance distributions for each shell. Parameters ---------- @@ -593,6 +602,7 @@ def plot_distance_distributions( Results from classify_cells_into_shells n_shells : int, optional Number of shells (will use ShellsUsed from results if not provided) + """ if n_shells is None: n_shells = classification_results.get( @@ -606,7 +616,7 @@ def plot_distance_distributions( distances_from_center = classification_results["DistancesFromCenter"] distances_from_exterior = classification_results["DistancesFromExterior"] - colors = plt.cm.RdYlBu_r(numpy.linspace(0, 1, n_shells)) + colors = plt.cm.RdYlBu_r(numpy.linspace(0, 1, n_shells)) # type: ignore[attr-defined] # Distance from center for shell in range(n_shells): diff --git a/src/zedprofiler/featurization/texture.py b/src/zedprofiler/featurization/texture.py index 32a9b4d..595ff2e 100644 --- a/src/zedprofiler/featurization/texture.py +++ b/src/zedprofiler/featurization/texture.py @@ -7,6 +7,8 @@ We want this module to be python api callable and scalable. """ +import contextlib + import mahotas import numpy import pandas @@ -19,8 +21,7 @@ def scale_image(image: numpy.ndarray, num_gray_levels: int = 256) -> numpy.ndarray: - """ - Scale the image to a specified number of gray levels. + """Scale the image to a specified number of gray levels. Example: 1024 gray levels will be scaled to 256 gray levels if num_gray_levels=256. An image with a pixel value of 0 will be scaled to 0 and a pixel value @@ -37,6 +38,7 @@ def scale_image(image: numpy.ndarray, num_gray_levels: int = 256) -> numpy.ndarr ------- numpy.ndarray The gray level scaled image of any shape. + """ outrange_mapping = { 256: "uint8", @@ -49,7 +51,7 @@ def scale_image(image: numpy.ndarray, num_gray_levels: int = 256) -> numpy.ndarr if out_range is None: raise ValueError( f"Unsupported num_gray_levels: {num_gray_levels}. " - f"Supported values are: {list(outrange_mapping.keys())}" + f"Supported values are: {list(outrange_mapping.keys())}", ) # scale the image to the requested gray levels return skimage.exposure.rescale_intensity( @@ -63,9 +65,8 @@ def compute_texture( # noqa: C901 object_loader: ObjectLoader, distance: int = 1, grayscale: int = 256, -) -> dict: - """ - Calculate texture features for each object in the image using Haralick features. +) -> pandas.DataFrame: + """Calculate texture features for each object in the image using Haralick features. The features are calculated for each object separately and the mean value is returned. @@ -81,32 +82,21 @@ def compute_texture( # noqa: C901 Returns ------- - dict - A dictionary containing the object ID, texture name, and texture value - with keys: - - object_id - - texture_name - - texture_value - - Texture names include: Angular Second Moment, Contrast, Correlation, - Variance, Inverse Difference Moment, Sum Average, Sum Variance, - Sum Entropy, Entropy, and related texture measures. - - - AngularSecondMoment - - Contrast - - Correlation - - Variance - - InverseDifferenceMoment - - SumAverage - - SumVariance - - SumEntropy - - Entropy - - DifferenceVariance - - DifferenceEntropy - - InformationMeasureOfCorrelation1 - - InformationMeasureOfCorrelation2 + pandas.DataFrame + Wide-format DataFrame with one row per object and one column per + Haralick texture feature (direction x feature_name x distance x grayscale), + plus Metadata columns. Feature names follow the pattern: + ``__Texture_---`` + + Haralick features measured per direction: + AngularSecondMoment, Contrast, Correlation, Variance, + InverseDifferenceMoment, SumAverage, SumVariance, SumEntropy, + Entropy, DifferenceVariance, DifferenceEntropy, + InformationMeasureOfCorrelation1, InformationMeasureOfCorrelation2 """ + if object_loader.label_image is None or object_loader.image is None: + return pandas.DataFrame() label_object = object_loader.label_image labels = object_loader.object_ids feature_names = [ @@ -127,7 +117,7 @@ def compute_texture( # noqa: C901 # set the number of directions based on the dimensionality of the image n_directions = 13 - output_texture_dict = { + output_texture_dict: dict[str, list] = { "Metadata_Object_ObjectID": [], "texture_name": [], "texture_value": [], @@ -149,6 +139,8 @@ def compute_texture( # noqa: C901 int(props["bbox-4"][i]), int(props["bbox-5"][i]), ) + # Allocate once before the loop so each label's slot persists + features = numpy.full((n_directions, 13, max(labels, default=0) or 1), numpy.nan) # loop through each label and get the bounding box # to compute features for the object for _, label in enumerate(labels): @@ -167,9 +159,8 @@ def compute_texture( # noqa: C901 if not numpy.any(object_mask): continue image_object[~object_mask] = 0 - features = numpy.empty((n_directions, 13, max(labels))) image_object = scale_image(image_object, num_gray_levels=grayscale) - try: + with contextlib.suppress(ValueError): # calculates 13 Haralick features for each direction (13) # and each object, and stores them in a 3D array features[:, :, label - 1] = mahotas.features.haralick( @@ -178,17 +169,16 @@ def compute_texture( # noqa: C901 distance=distance, compute_14th_feature=False, ) - except ValueError: - features = numpy.full(len(feature_names), numpy.nan, dtype=float) # iterate through the direction, feature, and object dimensions # of the features array to populate the output dictionary for direction, direction_features in enumerate(features): direction_str = f"{direction:02d}" for feature_name, feature in zip(feature_names, direction_features): - for object_id, feature_value in zip(labels, feature): + for object_id in labels: + feature_value = feature[object_id - 1] output_texture_dict["Metadata_Object_ObjectID"].append(object_id) output_texture_dict["texture_name"].append( - f"{feature_name}-{distance}-{direction_str}-{grayscale}" + f"{feature_name}-{distance}-{direction_str}-{grayscale}", ) output_texture_dict["texture_value"].append(feature_value) final_df = pandas.DataFrame(output_texture_dict) diff --git a/src/zedprofiler/featurization/volumesizeshape.py b/src/zedprofiler/featurization/volumesizeshape.py index b68e22c..237a2d8 100644 --- a/src/zedprofiler/featurization/volumesizeshape.py +++ b/src/zedprofiler/featurization/volumesizeshape.py @@ -4,6 +4,7 @@ from collections.abc import Sequence from importlib import import_module +from types import ModuleType from typing import Protocol import numpy as np @@ -21,6 +22,12 @@ class SupportsImageSetLoader(Protocol): anisotropy_spacing: tuple[float, float, float] +class _SupportsImageSetName(Protocol): + """Minimal image-set-loader interface for name access.""" + + image_set_name: str | None + + class SupportsObjectLoader(Protocol): """Minimal object loader interface required by this module.""" @@ -29,6 +36,9 @@ class SupportsObjectLoader(Protocol): # (0 is typically reserved for background) label_image: np.ndarray object_ids: Sequence[int] + compartment: str + channel: str + image_set_loader: _SupportsImageSetName def _empty_feature_result() -> dict[str, list[float]]: @@ -56,7 +66,7 @@ def _empty_feature_result() -> dict[str, list[float]]: def compute_volume_size_shape( image_set_loader: SupportsImageSetLoader | None = None, object_loader: SupportsObjectLoader | None = None, -) -> dict[str, list[float]]: +) -> pandas.DataFrame | dict[str, list[float]]: """Compute volume/size/shape features for one object loader. Supports two invocation modes: @@ -70,7 +80,7 @@ def compute_volume_size_shape( if image_set_loader is None or object_loader is None: raise ZedProfilerError( "volumesizeshape.compute requires both image_set_loader and " - "object_loader for execution." + "object_loader for execution.", ) return measure_3D_volume_size_shape( @@ -79,13 +89,13 @@ def compute_volume_size_shape( ) -def _get_skimage_measure() -> object: +def _get_skimage_measure() -> ModuleType: """Return `skimage.measure` or raise a user-facing dependency error.""" try: return import_module("skimage.measure") except ImportError as exc: raise ZedProfilerError( - "volumesizeshape requires scikit-image for area/size/shape computation." + "volumesizeshape requires scikit-image for area/size/shape computation.", ) from exc @@ -115,7 +125,7 @@ def calculate_surface_area( def measure_3D_volume_size_shape( image_set_loader: SupportsImageSetLoader, object_loader: SupportsObjectLoader, -) -> dict[str, list[float]]: +) -> pandas.DataFrame: """Measure volume/size/shape features for each non-zero label object.""" measure = _get_skimage_measure() @@ -164,16 +174,16 @@ def measure_3D_volume_size_shape( features_to_record["Extent"].append(props["extent"].item()) features_to_record["EulerNumber"].append(props["euler_number"].item()) features_to_record["EquivalentDiameter"].append( - props["equivalent_diameter"].item() + props["equivalent_diameter"].item(), ) try: features_to_record["SurfaceArea"].append( calculate_surface_area( - label_object=label_object, + label_object=subset_lab_object, props=props, spacing=spacing, - ) + ), ) except (RuntimeError, ValueError): features_to_record["SurfaceArea"].append(np.nan) diff --git a/src/zedprofiler/image_utils/image_utils.py b/src/zedprofiler/image_utils/image_utils.py index f3dc58c..ef99299 100644 --- a/src/zedprofiler/image_utils/image_utils.py +++ b/src/zedprofiler/image_utils/image_utils.py @@ -1,16 +1,14 @@ -from typing import Tuple, Union - import numpy -BBoxCoord = Union[int, float] +BBoxCoord = int BBox3D = tuple[BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord, BBoxCoord] def select_objects_from_label( - label_image: numpy.ndarray, object_ids: list + label_image: numpy.ndarray, + object_ids: list | numpy.ndarray | int, ) -> numpy.ndarray: - """ - Selects objects from a label image based on the provided object IDs. + """Selects objects from a label image based on the provided object IDs. Parameters ---------- @@ -23,17 +21,24 @@ def select_objects_from_label( ------- numpy.ndarray The label image with only the selected objects. + """ label_image = label_image.copy() - label_image[label_image != object_ids] = 0 + if isinstance(object_ids, (list, numpy.ndarray)): + label_image[~numpy.isin(label_image, object_ids)] = 0 + else: + label_image[label_image != object_ids] = 0 return label_image def expand_box( - min_coor: int, max_coord: int, current_min: int, current_max: int, expand_by: int -) -> Union[Tuple[int, int], ValueError]: - """ - Expand the bounding box of an object in a 3D image. + min_coor: int, + max_coord: int, + current_min: int, + current_max: int, + expand_by: int, +) -> tuple[int, int]: + """Expand the bounding box of an object in a 3D image. Parameters ---------- @@ -52,13 +57,17 @@ def expand_box( Returns ------- - Union[Tuple[int, int], ValueError] + Tuple[int, int] The new minimum and maximum coordinates of the bounding box. - Raises ValueError if the expansion is not possible. - """ + Raises + ------ + ValueError + If the expansion is not possible. + + """ if max_coord - min_coor - (current_max - current_min) < expand_by: - return ValueError("Cannot expand box by the requested amount") + raise ValueError("Cannot expand box by the requested amount") while expand_by > 0: if current_min > min_coor: current_min -= 1 @@ -75,8 +84,7 @@ def new_crop_border( bbox2: BBox3D, image: numpy.ndarray, ) -> tuple[BBox3D, BBox3D]: - """ - Expand the bounding boxes of two objects in a 3D image to match their sizes. + """Expand the bounding boxes of two objects in a 3D image to match their sizes. Parameters ---------- @@ -91,9 +99,12 @@ def new_crop_border( ------- tuple[BBox3D, BBox3D] The new bounding boxes of the two objects. + Raises + ------ ValueError If the expansion is not possible. + """ i1z1, i1y1, i1x1, i1z2, i1y2, i1x2 = bbox1 i2z1, i2y1, i2x1, i2z2, i2y2, i2x2 = bbox2 @@ -168,8 +179,7 @@ def crop_3D_image( image: numpy.ndarray, bbox: BBox3D, ) -> numpy.ndarray: - """ - Crop a 3D image to the bounding box of a mask. + """Crop a 3D image to the bounding box of a mask. Parameters ---------- @@ -182,6 +192,7 @@ def crop_3D_image( ------- numpy.ndarray The cropped image. + """ z1, y1, x1, z2, y2, x2 = bbox return image[z1:z2, y1:y2, x1:x2] @@ -193,8 +204,7 @@ def single_3D_image_expand_bbox( expand_pixels: int, anisotropy_factor: int, ) -> tuple[int, int, int, int, int, int]: - """ - Expand the bbox in a way that keeps the crop within the + """Expand the bbox in a way that keeps the crop within the confines of the image volume Parameters @@ -220,6 +230,7 @@ def single_3D_image_expand_bbox( tuple[int, int, int, int, int, int] Updated bbox in the format (zmin, ymin, xmin, zmax, ymax, xmax) after expansion and adjustment for anisotropy + """ z1, y1, x1, z2, y2, x2 = bbox zmin, ymin, xmin = 0, 0, 0 @@ -256,8 +267,7 @@ def single_3D_image_expand_bbox( def check_for_xy_squareness(bbox: tuple[int, int, int, int, int, int]) -> float: - """ - This function returns the ratio of the x length to the y length + """This function returns the ratio of the x length to the y length A value of 1 indicates a square bbox is present Parameters @@ -272,13 +282,14 @@ def check_for_xy_squareness(bbox: tuple[int, int, int, int, int, int]) -> float: float The ratio of the y length to the x length of the bbox. A value of 1 indicates a square bbox. + """ _z_min, y_min, x_min, _z_max, y_max, x_max = bbox x_length = x_max - x_min if x_length == 0: raise ValueError( "Cannot compute xy squareness for bbox with zero width in x dimension " - f"(bbox={bbox})." + f"(bbox={bbox}).", ) xy_squareness = (y_max - y_min) / x_length return xy_squareness @@ -287,8 +298,7 @@ def check_for_xy_squareness(bbox: tuple[int, int, int, int, int, int]) -> float: def square_off_xy_crop_bbox( bbox: tuple[int, int, int, int, int, int], ) -> tuple[int, int, int, int, int, int]: - """ - Adjust the bbox to be square in the XY plane. + """Adjust the bbox to be square in the XY plane. The function computes the new bbox from the current X/Y dimensions. @@ -307,6 +317,7 @@ def square_off_xy_crop_bbox( (z_min, new_y_min, new_x_min, z_max, new_y_max, new_x_max) Each value is an integer pixel coordinate in that dimension. + """ zmin, ymin, xmin, zmax, ymax, xmax = bbox # first find the larger dimension between x and y @@ -317,11 +328,10 @@ def square_off_xy_crop_bbox( new_ymin = int(ymin - (x_size - y_size) / 2) new_ymax = int(ymax + (x_size - y_size) / 2) return (zmin, new_ymin, xmin, zmax, new_ymax, xmax) - elif y_size > x_size: + if y_size > x_size: # need to expand x dimension new_xmin = int(xmin - (y_size - x_size) / 2) new_xmax = int(xmax + (y_size - x_size) / 2) return (zmin, ymin, new_xmin, zmax, ymax, new_xmax) - else: - # already square - return bbox + # already square + return bbox diff --git a/tests/IO/feature_writing_utils_test.py b/tests/IO/feature_writing_utils_test.py index be0e3eb..e5c97b1 100644 --- a/tests/IO/feature_writing_utils_test.py +++ b/tests/IO/feature_writing_utils_test.py @@ -76,7 +76,7 @@ def test_format_morphology_feature_name( def test_feature_name_component_schema_rejects_missing_columns() -> None: """Pandera schema should fail when required component columns are missing.""" component_frame = pd.DataFrame( - [{"compartment": "nucleus", "channel": "dapi", "feature_type": "area"}] + [{"compartment": "nucleus", "channel": "dapi", "feature_type": "area"}], ) with pytest.raises(SchemaError): FEATURE_NAME_COMPONENT_SCHEMA.validate(component_frame) @@ -91,8 +91,8 @@ def test_coerce_feature_name_components_uses_cleanup_function() -> None: "channel": "gfp.channel", "feature_type": "mean intensity", "measurement": "normalized/value", - } - ] + }, + ], ) parsed = _coerce_feature_name_components(input_df) assert parsed.iloc[0].to_dict() == { @@ -110,8 +110,8 @@ def test_coerce_feature_name_components_skips_missing_columns() -> None: { "compartment": "cell_body", "measurement": "normalized/value", - } - ] + }, + ], ) parsed = _coerce_feature_name_components(input_df) @@ -138,7 +138,7 @@ def test_coerce_dataframe_column_names_to_strings_keeps_data() -> None: def test_feature_metadata_enforces_runtime_types() -> None: - """beartype should validate dataclass init annotations at runtime.""" + """Beartype should validate dataclass init annotations at runtime.""" with pytest.raises(BeartypeCallHintParamViolation): FeatureMetadata( compartment=1, @@ -233,7 +233,7 @@ def test_save_features_as_parquet_sanitizes_metadata_in_filename( def test_save_features_as_parquet_requires_path_type() -> None: - """beartype should reject non-Path inputs for parent_path.""" + """Beartype should reject non-Path inputs for parent_path.""" with pytest.raises(BeartypeCallHintParamViolation): save_features_as_parquet( "not-a-path", diff --git a/tests/IO/test_loading_classes.py b/tests/IO/test_loading_classes.py index 17ad002..831987a 100644 --- a/tests/IO/test_loading_classes.py +++ b/tests/IO/test_loading_classes.py @@ -102,7 +102,7 @@ def get_image_data(self, order: str) -> np.ndarray: assert result.shape == (2, 2, 2) def test_image_loading_requires_path_type(self) -> None: - """beartype should reject non-Path inputs for _image_loading.""" + """Beartype should reject non-Path inputs for _image_loading.""" with pytest.raises(BeartypeCallHintParamViolation): _image_loading("/tmp/fake-image.tif") @@ -189,14 +189,14 @@ def test_get_unique_objects_in_compartments_filters_background(self) -> None: TWO_LABEL, ] - def test_get_unique_objects_empty_compartments_raises_type_error(self) -> None: - """Current behavior sets compartments to None then iterates and raises.""" + def test_get_unique_objects_empty_compartments_returns_empty(self) -> None: + """Empty compartments list returns early.""" loader = ImageSetLoader.__new__(ImageSetLoader) loader.image_set_dict = {} loader.compartments = [] - with pytest.raises(TypeError): - loader.get_unique_objects_in_compartments() + loader.get_unique_objects_in_compartments() + assert loader.unique_compartment_objects == {} def test_get_image_and_get_anisotropy(self) -> None: """Simple accessors return the expected image and anisotropy ratio.""" diff --git a/tests/conftest.py b/tests/conftest.py index 9519594..349e378 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ def my_data() -> str: str A deterministic string value used by tests that require simple text input. + """ return "Hello, differently!" @@ -35,6 +36,7 @@ def minimal_profile() -> Profile: Profile A profile containing a valid 3D image array and empty feature and metadata mappings. + """ return Profile( image_array=np.random.rand(8, 16, 16), @@ -52,6 +54,7 @@ def small_image_profile() -> Profile: Profile A profile with image shape ``(4, 8, 8)`` plus representative intensity features and metadata fields. + """ return Profile( image_array=np.random.rand(4, 8, 8).astype(np.float32), @@ -76,6 +79,7 @@ def medium_image_profile() -> Profile: Profile A profile with image shape ``(16, 32, 32)`` and a mixed set of intensity, texture, and morphology feature values. + """ return Profile( image_array=np.random.rand(16, 32, 32).astype(np.float32), @@ -104,6 +108,7 @@ def large_image_profile() -> Profile: Profile A profile with image shape ``(32, 64, 64)`` and a richer collection of features and metadata fields. + """ return Profile( image_array=np.random.rand(32, 64, 64).astype(np.float32), @@ -140,6 +145,7 @@ def intensity_profile() -> Profile: Profile A profile emphasizing intensity-derived measurements across multiple channels and compartments. + """ return Profile( image_array=np.random.rand(16, 48, 48).astype(np.float32), @@ -171,6 +177,7 @@ def texture_profile() -> Profile: Profile A profile containing entropy, gabor, and contrast style texture measurements. + """ return Profile( image_array=np.random.rand(12, 40, 40).astype(np.float32), @@ -200,6 +207,7 @@ def morphology_profile() -> Profile: Profile A profile emphasizing morphology metrics such as volume, surface area, and sphericity. + """ return Profile( image_array=np.random.rand(20, 56, 56).astype(np.float32), @@ -231,6 +239,7 @@ def colocalization_profile() -> Profile: Profile A profile containing correlation and overlap metrics for paired channel combinations. + """ return Profile( image_array=np.random.rand(14, 44, 44).astype(np.float32), @@ -258,6 +267,7 @@ def granularity_profile() -> Profile: ------- Profile A profile with a sequence of granularity spectrum measurements. + """ return Profile( image_array=np.random.rand(16, 48, 48).astype(np.float32), @@ -289,6 +299,7 @@ def neighbors_profile() -> Profile: Profile A profile with adjacency, neighbor count, and nearest-distance style neighborhood features. + """ return Profile( image_array=np.random.rand(10, 32, 32).astype(np.float32), @@ -316,6 +327,7 @@ def complete_profile() -> Profile: Profile A profile that mixes intensity, texture, morphology, granularity, colocalization, and neighbor-derived measurements. + """ return Profile( image_array=np.random.rand(24, 64, 64).astype(np.float32), @@ -379,6 +391,7 @@ def all_profiles( list[Profile] A list of profiles ordered to provide size diversity for parameterized tests. + """ return [ small_image_profile, @@ -405,6 +418,7 @@ def all_feature_type_profiles( list[Profile] Profiles specialized for intensity, texture, morphology, colocalization, granularity, and neighbor-based tests. + """ fixture_names = [ "intensity_profile", @@ -427,7 +441,7 @@ def all_feature_type_profiles( (12, 24, 24), (16, 32, 32), (20, 40, 40), - ] + ], ) def varying_image_sizes(request: pytest.FixtureRequest) -> tuple[int, int, int]: """Provide parameterized image dimensions for profile generation. @@ -441,6 +455,7 @@ def varying_image_sizes(request: pytest.FixtureRequest) -> tuple[int, int, int]: ------- tuple[int, int, int] A 3-tuple ``(z, y, x)`` describing image dimensions for test cases. + """ return request.param @@ -462,6 +477,7 @@ def profile_with_varying_size( Profile A profile containing a random 3D float32 image of the requested shape, a minimal feature set, and minimal metadata. + """ z, y, x = varying_image_sizes return Profile( diff --git a/tests/featurization/conftest.py b/tests/featurization/conftest.py index 94d002f..69e3ee9 100644 --- a/tests/featurization/conftest.py +++ b/tests/featurization/conftest.py @@ -12,10 +12,10 @@ def rng() -> np.random.Generator: @beartype def make_pair( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """ - Make pairs of images to use for testing + """Make pairs of images to use for testing This is a support function for coloaclization Featurization testing @@ -30,6 +30,7 @@ def make_pair( ------- tuple[np.ndarray, np.ndarray, np.ndarray] A tuple containing the label image, image 1, and image 2. + """ label = np.zeros(shape, dtype=int) z, y, x = center diff --git a/tests/featurization/test_colocalization.py b/tests/featurization/test_colocalization.py index 8c5e26d..bf8660d 100644 --- a/tests/featurization/test_colocalization.py +++ b/tests/featurization/test_colocalization.py @@ -39,7 +39,8 @@ def to_array(_cls, v: object) -> np.ndarray: @pytest.mark.parametrize("shape,center", [((7, 7, 7), (3, 3, 3))]) def test_compute_colocalization_basic( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> None: imgset = ImageSetLoaderModel() label, im1, im2 = make_pair(shape, center) @@ -92,7 +93,12 @@ def test_prepare_two_images_for_colocalization_crops() -> None: im2[3, 3, 3] = expected_peak_im2 cropped1, cropped2 = prepare_two_images_for_colocalization( - label, label, im1, im2, 1, 1 + label, + label, + im1, + im2, + 1, + 1, ) assert isinstance(cropped1, np.ndarray) and isinstance(cropped2, np.ndarray) diff --git a/tests/featurization/test_granularity.py b/tests/featurization/test_granularity.py index b1ac407..09fbd87 100644 --- a/tests/featurization/test_granularity.py +++ b/tests/featurization/test_granularity.py @@ -39,7 +39,8 @@ def ensure_array(_cls, v: object) -> np.ndarray: @beartype def make_image_and_label( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> tuple[np.ndarray, np.ndarray]: image = np.zeros(shape, dtype=float) label = np.zeros(shape, dtype=int) @@ -51,7 +52,8 @@ def make_image_and_label( @pytest.mark.parametrize("shape,center", [((12, 12, 12), (6, 6, 6))]) def test_compute_granularity_basic( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> None: img, lab = make_image_and_label(shape, center) imgset = ImageSetLoaderModel() @@ -154,7 +156,10 @@ class Dummy: # With mask excluding pixels, function should still run and return DataFrame df = compute_granularity( - Dummy(), radius=1, granular_spectrum_length=3, image_mask=mask + Dummy(), + radius=1, + granular_spectrum_length=3, + image_mask=mask, ) assert isinstance(df, pd.DataFrame) diff --git a/tests/featurization/test_intensity.py b/tests/featurization/test_intensity.py index 2f01168..d41759d 100644 --- a/tests/featurization/test_intensity.py +++ b/tests/featurization/test_intensity.py @@ -31,7 +31,8 @@ def to_array(_cls, v: object) -> np.ndarray: @beartype def make_label_and_image( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> tuple[np.ndarray, np.ndarray]: image = np.zeros(shape, dtype=float) label = np.zeros(shape, dtype=int) @@ -43,7 +44,8 @@ def make_label_and_image( @pytest.mark.parametrize("shape,center", [((6, 6, 6), (3, 3, 3))]) def test_compute_intensity_basic( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> None: img, lab = make_label_and_image(shape, center) imgset = ImageSetLoaderModel() diff --git a/tests/featurization/test_neighbors.py b/tests/featurization/test_neighbors.py index cb5b3f3..30829f3 100644 --- a/tests/featurization/test_neighbors.py +++ b/tests/featurization/test_neighbors.py @@ -41,7 +41,8 @@ def to_array(_cls, v: object) -> np.ndarray: @beartype def make_two_labels( - shape: tuple[int, int, int], centers: list[tuple[int, int, int]] + shape: tuple[int, int, int], + centers: list[tuple[int, int, int]], ) -> np.ndarray: lab = np.zeros(shape, dtype=int) for i, (z, y, x) in enumerate(centers, start=1): @@ -56,13 +57,16 @@ def make_two_labels( ], ) def test_compute_neighbors_counts( - shape: tuple[int, int, int], centers: list[tuple[int, int, int]] + shape: tuple[int, int, int], + centers: list[tuple[int, int, int]], ) -> None: lab = make_two_labels(shape, centers) imgset = ImageSetLoaderModel() obj_ids = sorted(set(lab.ravel()) - {0}) loader = ObjectLoaderModel( - label_image=lab, object_ids=obj_ids, image_set_loader=imgset + label_image=lab, + object_ids=obj_ids, + image_set_loader=imgset, ) df = compute_neighbors(loader, distance_threshold=5, anisotropy_factor=1) @@ -99,30 +103,37 @@ def test_get_coordinates_and_distances_and_centroid() -> None: assert centroid.shape == (3,) dists = euclidean_distance_from_centroid( - coords[["x", "y", "z"]].to_numpy(), centroid + coords[["x", "y", "z"]].to_numpy(), + centroid, ) expected_coordinate_count = len(coords) assert dists.shape[0] == expected_coordinate_count def test_mahalanobis_small_and_regularized_and_singular() -> None: - # small sample -> fallback to euclidean + # small sample -> fallback to euclidean (intentional UserWarning expected) coords_small = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) centroid = np.mean(coords_small, axis=0) - md_small = mahalanobis_distance_from_centroid( - coords_small, centroid, min_cells_threshold=50 - ) + with pytest.warns(UserWarning): + md_small = mahalanobis_distance_from_centroid( + coords_small, + centroid, + min_cells_threshold=50, + ) ed_small = euclidean_distance_from_centroid(coords_small, centroid) assert np.allclose(md_small, ed_small) # regularized branch with many identical points -> singular covariance - # -> pseudo-inverse + # -> pseudo-inverse (intentional UserWarning expected) repeated_count = 30 coords_singular = np.tile(np.array([1.0, 2.0, 3.0]), (repeated_count, 1)) centroid2 = np.mean(coords_singular, axis=0) - md_sing = mahalanobis_distance_from_centroid( - coords_singular, centroid2, min_cells_threshold=50 - ) + with pytest.warns(UserWarning): + md_sing = mahalanobis_distance_from_centroid( + coords_singular, + centroid2, + min_cells_threshold=50, + ) assert md_sing.shape[0] == repeated_count # distances should be zeros because all identical assert np.allclose(md_sing, 0.0) @@ -146,7 +157,9 @@ def test_create_results_dataframe_and_errors_and_plots() -> None: coords_df = pd.DataFrame({"x": [0, 1, 2], "y": [0, 1, 2], "z": [0, 1, 2]}) fig1 = visualize_organoid_shells( - coords_df, results, centroid=np.array([1.0, 1.0, 1.0]) + coords_df, + results, + centroid=np.array([1.0, 1.0, 1.0]), ) assert hasattr(fig1, "axes") diff --git a/tests/featurization/test_neighbors_additional.py b/tests/featurization/test_neighbors_additional.py index 328d0e9..2da68d7 100644 --- a/tests/featurization/test_neighbors_additional.py +++ b/tests/featurization/test_neighbors_additional.py @@ -84,29 +84,37 @@ def test_get_coordinates_and_distances_and_centroid() -> None: assert centroid.shape == (3,) dists = euclidean_distance_from_centroid( - coords[["x", "y", "z"]].to_numpy(), centroid + coords[["x", "y", "z"]].to_numpy(), + centroid, ) expected_coordinate_count = len(coords) assert dists.shape[0] == expected_coordinate_count def test_mahalanobis_small_and_regularized_and_singular() -> None: - # small sample -> fallback to euclidean + # small sample -> fallback to euclidean (intentional UserWarning expected) coords_small = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) centroid = np.mean(coords_small, axis=0) - md_small = mahalanobis_distance_from_centroid( - coords_small, centroid, min_cells_threshold=50 - ) + with pytest.warns(UserWarning): + md_small = mahalanobis_distance_from_centroid( + coords_small, + centroid, + min_cells_threshold=50, + ) ed_small = euclidean_distance_from_centroid(coords_small, centroid) assert np.allclose(md_small, ed_small) # regularized branch with many identical points -> singular covariance + # -> pseudo-inverse (intentional UserWarning expected) repeated_count = 30 coords_singular = np.tile(np.array([1.0, 2.0, 3.0]), (repeated_count, 1)) centroid2 = np.mean(coords_singular, axis=0) - md_sing = mahalanobis_distance_from_centroid( - coords_singular, centroid2, min_cells_threshold=50 - ) + with pytest.warns(UserWarning): + md_sing = mahalanobis_distance_from_centroid( + coords_singular, + centroid2, + min_cells_threshold=50, + ) assert md_sing.shape[0] == repeated_count # distances should be zeros because all identical assert np.allclose(md_sing, 0.0) @@ -115,21 +123,25 @@ def test_mahalanobis_small_and_regularized_and_singular() -> None: def test_classify_cells_into_shells_empty_and_reduction_and_methods() -> None: # empty coords res, cent = classify_cells_into_shells( - pd.DataFrame(columns=["Metadata_Object_ObjectID", "x", "y", "z"]) + pd.DataFrame(columns=["Metadata_Object_ObjectID", "x", "y", "z"]), ) assert res["Metadata_Object_ObjectID"] == [] assert cent is None - # small set with requested large n_shells -> will reduce + # small set with requested large n_shells -> will reduce (intentional UserWarning) coords = { "Metadata_Object_ObjectID": [1, 2, 3, 4, 5, 6], "x": [0, 1, 2, 3, 4, 5], "y": [0, 1, 2, 3, 4, 5], "z": [0, 1, 2, 3, 4, 5], } - results, _centroid = classify_cells_into_shells( - coords, n_shells=10, method="euclidean", min_cells_per_shell=3 - ) + with pytest.warns(UserWarning): + results, _centroid = classify_cells_into_shells( + coords, + n_shells=10, + method="euclidean", + min_cells_per_shell=3, + ) # max_shells = max(2, 6//3==2) -> will reduce to 2 expected_shells = 2 assert results["ShellsUsed"] == expected_shells @@ -153,7 +165,9 @@ def test_create_results_dataframe_and_errors_and_plots() -> None: coords_df = pd.DataFrame({"x": [0, 1, 2], "y": [0, 1, 2], "z": [0, 1, 2]}) fig1 = visualize_organoid_shells( - coords_df, results, centroid=np.array([1.0, 1.0, 1.0]) + coords_df, + results, + centroid=np.array([1.0, 1.0, 1.0]), ) assert hasattr(fig1, "axes") diff --git a/tests/featurization/test_texture.py b/tests/featurization/test_texture.py index aaee06a..233cf43 100644 --- a/tests/featurization/test_texture.py +++ b/tests/featurization/test_texture.py @@ -33,7 +33,8 @@ def to_array(_cls, v: object) -> np.ndarray: @beartype def make_texture_image( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> tuple[np.ndarray, np.ndarray]: image = np.zeros(shape, dtype=int) label = np.zeros(shape, dtype=int) @@ -45,12 +46,16 @@ def make_texture_image( @pytest.mark.parametrize("shape,center", [((15, 15, 15), (7, 7, 7))]) def test_compute_texture_basic( - shape: tuple[int, int, int], center: tuple[int, int, int] + shape: tuple[int, int, int], + center: tuple[int, int, int], ) -> None: image, label = make_texture_image(shape, center) imgset = ImageSetLoaderModel() loader = ObjectLoaderModel( - image=image, label_image=label, object_ids=[1], image_set_loader=imgset + image=image, + label_image=label, + object_ids=[1], + image_set_loader=imgset, ) df = compute_texture(loader, distance=1, grayscale=256) diff --git a/tests/featurization/test_volumesizeshape.py b/tests/featurization/test_volumesizeshape.py index d3c683f..02637f0 100644 --- a/tests/featurization/test_volumesizeshape.py +++ b/tests/featurization/test_volumesizeshape.py @@ -33,7 +33,8 @@ def ensure_ndarray(_cls, v: object) -> np.ndarray: @beartype def make_label_image( - shape: tuple[int, int, int], centers: list[tuple[int, int, int]] + shape: tuple[int, int, int], + centers: list[tuple[int, int, int]], ) -> np.ndarray: lab = np.zeros(shape, dtype=int) for i, (z0, y0, x0) in enumerate(centers, start=1): @@ -53,7 +54,8 @@ def make_label_image( ], ) def test_compute_volume_size_shape_returns_dataframe( - shape: tuple[int, int, int], centers: list[tuple[int, int, int]] + shape: tuple[int, int, int], + centers: list[tuple[int, int, int]], ) -> None: imgset = ImageSetLoaderModel(anisotropy_spacing=(1.0, 1.0, 1.0)) label = make_label_image(shape, centers) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 9d1019a..9c95e16 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -49,7 +49,7 @@ def expected_values_config_path(tmp_path: Path) -> Path: [expected_values] compartments = ["Nuclei", "Cytoplasm", "Cell", "Organoid"] channels = ["DNA", "AGP", "ER", "Mito"] -""".strip() +""".strip(), ) return config_path diff --git a/tests/test_image_utils.py b/tests/test_image_utils.py index 311c470..f8e9b7a 100644 --- a/tests/test_image_utils.py +++ b/tests/test_image_utils.py @@ -26,9 +26,9 @@ def test_expand_box_expands_from_min_edge_first() -> None: assert (new_min, new_max) == (1, 6) -def test_expand_box_returns_value_error_when_impossible() -> None: - result = expand_box(0, 5, 1, 4, 3) - assert isinstance(result, ValueError) +def test_expand_box_raises_value_error_when_impossible() -> None: + with pytest.raises(ValueError): + expand_box(0, 5, 1, 4, 3) def test_new_crop_border_expands_first_bbox_when_second_is_larger() -> None: diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 36383ba..55e192f 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -35,7 +35,7 @@ def test_end_to_end_feature_extraction_and_save(self) -> None: "Metadata_Object_ObjectID": [1, 2, 3], "volume": [100.5, 200.3, 150.7], "diameter": [10.2, 12.5, 11.8], - } + }, ) # Create metadata @@ -106,7 +106,7 @@ def test_empty_dataframe_save_restore(self) -> None: "Metadata_Object_ObjectID": pd.Series([], dtype="int64"), "feature1": pd.Series([], dtype="float64"), "feature2": pd.Series([], dtype="float64"), - } + }, ) metadata = FeatureMetadata( @@ -169,7 +169,10 @@ def test_very_long_feature_names(self) -> None: def test_special_characters_in_compartment_names(self) -> None: """Test special characters in compartment names.""" result = format_morphology_feature_name( - "cell/compartment", "ch_1", "feat.type", "meas" + "cell/compartment", + "ch_1", + "feat.type", + "meas", ) assert isinstance(result, str) # Should have replaced delimiters diff --git a/tests/test_profile_fixtures.py b/tests/test_profile_fixtures.py index 986e20e..3c0c84f 100644 --- a/tests/test_profile_fixtures.py +++ b/tests/test_profile_fixtures.py @@ -124,7 +124,8 @@ def test_all_profiles_collection(self, all_profiles: list[Profile]) -> None: assert sizes == sorted(sizes) def test_all_feature_type_profiles( - self, all_feature_type_profiles: list[Profile] + self, + all_feature_type_profiles: list[Profile], ) -> None: """Verify feature type collection has all feature types.""" assert len(all_feature_type_profiles) == EXPECTED_FEATURE_TYPE_PROFILES_COUNT diff --git a/tests/test_robustness.py b/tests/test_robustness.py index 1fc184c..f19e9f9 100644 --- a/tests/test_robustness.py +++ b/tests/test_robustness.py @@ -26,7 +26,10 @@ class TestRobustness: def test_format_name_with_all_delimiters(self) -> None: """Test formatting with all types of delimiters.""" result = format_morphology_feature_name( - "cell_part", "channel.name", "feature type", "measurement/value" + "cell_part", + "channel.name", + "feature type", + "measurement/value", ) assert isinstance(result, str) assert "_" in result @@ -45,7 +48,7 @@ def test_dataframe_with_various_dtypes(self) -> None: "float_col": [1.1, 2.2, 3.3], "str_col": ["a", "b", "c"], "bool_col": [True, False, True], - } + }, ) metadata = FeatureMetadata( @@ -72,7 +75,7 @@ def test_large_dataframe_handling(self) -> None: { f"feature_{i}": range(LARGE_DATAFRAME_ROWS) for i in range(LARGE_DATAFRAME_COLUMNS) - } + }, ) metadata = FeatureMetadata( diff --git a/uv.lock b/uv.lock index a6e320e..5f8d891 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <3.15" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -955,7 +955,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, @@ -963,7 +965,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -971,7 +975,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, @@ -979,7 +985,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, + { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, @@ -987,7 +995,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, @@ -4241,6 +4251,7 @@ dependencies = [ { name = "pyarrow" }, { name = "pydantic" }, { name = "scikit-image" }, + { name = "scipy" }, { name = "tomli" }, ] @@ -4283,6 +4294,7 @@ requires-dist = [ { name = "pyarrow", specifier = ">=23.0.1" }, { name = "pydantic", specifier = ">=2.10" }, { name = "scikit-image", specifier = ">=0.26" }, + { name = "scipy", specifier = ">=1.14" }, { name = "tomli", specifier = ">=2.4.1" }, ]